Compare commits

..

67 Commits

Author SHA1 Message Date
YoVinchen 4ab9cd1371 Merge branch 'main' into fix/common-config-sync-improvements 2026-02-12 23:09:57 +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
YoVinchen 0c6d429fdb feat(omo): unify common config enabled via provider meta for OpenCode 2026-02-12 12:01:50 +08:00
YoVinchen b9afcbf2b0 Merge branch 'main' into fix/common-config-sync-improvements
# Conflicts:
#	src-tauri/src/commands/config.rs
#	src-tauri/src/services/provider/mod.rs
#	src/components/providers/forms/ProviderForm.tsx
2026-02-12 10:30:31 +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
YoVinchen bf0be82112 Merge branch 'main' into fix/common-config-sync-improvements 2026-02-06 16:33:15 +08:00
YoVinchen c7b8e6debc Merge branch 'main' into fix/common-config-sync-improvements 2026-02-05 16:55:09 +08:00
YoVinchen 6634a95c38 Merge branch 'main' into fix/common-config-sync-improvements 2026-02-04 10:26:24 +08:00
YoVinchen 1580e87303 Merge branch 'main' into fix/common-config-sync-improvements 2026-02-02 22:03:43 +08:00
YoVinchen ea29bc20a0 merge: resolve conflicts between fix/common-config-sync-improvements and main
Merged both feature sets:
- common-config: commonConfigEnabled, commonConfigEnabledByApp fields
- api-format: apiFormat field for Claude provider OpenAI compatibility

Key resolution in live.rs: apply sanitize_claude_settings_for_live()
to merged config before writing to ensure internal fields are stripped.
2026-01-31 21:03:08 +08:00
YoVinchen 0ef8c127c5 fix(common-config): improve error handling, layer separation, and reliability across sync system
P0 Fixes:
- Enable toml `preserve_order` feature for reliable key ordering
- Extract pure detection functions to utils/commonConfigDetection.ts (fix layer inversion)
- Return structured result from preserveCodexConfigFormat to prevent data loss

P1 Fixes:
- Handle pure TOML strings in Codex config instead of silent skip
- Surface extractDiff errors in useCodexCommonConfig instead of swallowing
- Log parse errors in detectCommonConfigEnabledByContent for debugging

P2 Fixes:
- Use parsing-based assertions in Rust TOML ordering tests
- Add schema warning when Codex config field is wrong type
- Add depth/node limits to isSubset function (prevent stack overflow)
- Replace hardcoded "{}" check with adapter.hasValidContent()

P3 Fixes:
- Refactor Gemini errors to structured GeminiConfigErrorInfo format
- Add mapGeminiErrorToI18n for type-safe error-to-i18n mapping
2026-01-31 20:39:28 +08:00
YoVinchen d66f196378 refactor(common-config): consolidate hasContent methods into adapters
- Add hasContent method to CommonConfigAdapter interface
- Move isSubset utility to configMerge.ts for reuse
- Export preserveCodexConfigFormat from adapters for hook use
- Add hasContentByAppType dispatcher for unified content detection
- Remove dead code from providerConfigUtils (hasCommonConfigSnippet,
  hasTomlCommonConfigSnippet, hasGeminiCommonConfigSnippet)
2026-01-31 15:23:49 +08:00
YoVinchen 580b32dd67 fix(codex): reorder merged TOML keys to put custom config first
When merging TOML configs, common config keys were appearing before
custom config keys. Add reorderMergedKeys() to ensure custom config
keys (model_provider, model, etc.) appear at the top, with common-only
keys appended after.
2026-01-31 15:04:57 +08:00
YoVinchen 51bf29582d fix(codex): show custom config before common config in merge preview
Replace deep_merge_toml with deep_merge_toml_preserve_order so that
custom config keys appear at the top and common-only keys appear below.
2026-01-30 10:38:59 +08:00
YoVinchen aa1231903f refactor(common-config): consolidate hooks and migrate Gemini to ENV format
- Delete redundant wrapper hooks (useCommonConfigSnippet, useGeminiCommonConfig)
- Change Gemini common config from JSON to ENV format (.env style)
- Add backend validation with forbidden keys filtering (GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL)
- Fix localStorage migration to skip empty parsed snippets
- Add error handling for silent JSON parse failures
- Clean up debug logs and unused types
2026-01-30 10:16:57 +08:00
YoVinchen b8a53f9e36 fix(test): move orphaned test into describe block
Move "clears loading flag when all mutations idle" test inside
the describe("useProviderActions") block for proper test isolation.
2026-01-30 00:11:11 +08:00
YoVinchen 06cde78945 chore: remove dead code from providerConfigUtils and config API
- Remove unused functions: updateCommonConfigSnippet, replaceCommonConfigSnippet,
  replaceGeminiCommonConfigSnippet, updateTomlCommonConfigSnippet,
  replaceTomlCommonConfigSnippet and their internal helpers (~600 lines)
- Remove deprecated getClaudeCommonConfigSnippet and setClaudeCommonConfigSnippet
- Remove unused imports (deepClone, deepMerge)
2026-01-29 23:03:56 +08:00
YoVinchen 0d67fba524 Merge branch 'main' into fix/common-config-sync-improvements 2026-01-29 22:38:35 +08:00
YoVinchen 3b61fab4b5 refactor: unify common config hooks with generic base hook and adapters
- Create useCommonConfigBase generic hook (~300 lines)
- Create commonConfigAdapters for Claude (JSON), Codex (TOML), Gemini (ENV/JSON)
- Refactor three hooks from ~1370 lines to ~430 lines (-940 lines)
- Extract useDarkMode hook from three ConfigSections components
- Remove dead code: backend _str functions, frontend JSON/TOML unused exports
- Deduplicate deepClone/deepMerge utilities
- Fix duplicate mod tests in provider.rs
2026-01-29 22:35:52 +08:00
YoVinchen 982f78af0f fix(codex): fix config.toml not updated after extracting common config
The handleExtract function assumed codexConfig was JSON format, but it's
actually a pure TOML string from useCodexConfigState. JSON.parse() failed
silently, causing onConfigChange not to be called.

Changes:
- Handle both pure TOML string and legacy JSON format
- Add missing i18n key for zh.json
2026-01-29 18:38:02 +08:00
YoVinchen 9776210da8 Merge branch 'main' into fix/common-config-sync-improvements 2026-01-29 14:44:42 +08:00
YoVinchen 188c7af2e4 Merge branch 'main' into fix/common-config-sync-improvements 2026-01-29 01:13:32 +08:00
YoVinchen de59facad6 feat(ui): add auto-height support for JsonEditor component
Add autoHeight prop to dynamically resize editor based on content lines.
Apply to config preview sections in Codex, Gemini, and CommonConfig forms.
2026-01-29 01:12:48 +08:00
YoVinchen 7974650ad8 style: format code across frontend and backend files 2026-01-29 00:59:05 +08:00
YoVinchen 3f68f78331 fix(config): defer common config save until form submission
- Change handleExtract and handleCommonConfigSnippetChange to use delayed save pattern
- Add hasUnsavedCommonConfig state to track pending changes
- Add getPendingCommonConfigSnippet and markCommonConfigSaved helper functions
- Remove immediate backend API calls, save only on form submit
- Add extractSuccessNeedSave and saveCommonConfigFailed i18n keys
2026-01-29 00:54:48 +08:00
YoVinchen 40373538d3 merge: resolve conflicts from main branch, keep both pricing and common config features 2026-01-27 12:49:36 +08:00
YoVinchen 11a9bb0c14 style(misc): inline format string variable 2026-01-27 12:35:59 +08:00
YoVinchen 9e25ecf475 fix(config): resolve common config sync issues across frontend and backend
- Fix backfill pollution: extract custom config from live when common config enabled
- Fix proxy backup: merge common config before backup to preserve full config
- Unify null override semantics: null no longer overrides in both frontend and backend
- Add missing i18n keys for common config UI
- Hide format button in readonly JsonEditor
- Add mapGeminiWarningToI18n for user-friendly warning messages
2026-01-27 12:28:46 +08:00
YoVinchen 733605ae5c merge: resolve conflict in live.rs, keep write_live_snapshot_with_merge
- Combine doc comments from both branches
- Keep write_live_snapshot_with_merge for common config runtime merge
- Fix indentation to match main branch style
2026-01-26 23:00:44 +08:00
YoVinchen 542d4635c4 fix(config): improve Gemini common config parsing robustness
- Add ENV format quote stripping (KEY="value" -> value)
- Add toast warnings for filtered forbidden keys
- Unify error codes with GEMINI_CONFIG_ERROR_CODES constants
- Remove duplicate isPlainObject, use shared implementation
- Fix type guard for filter entries
2026-01-26 22:46:24 +08:00
YoVinchen 4e4a445922 fix(config): improve Gemini merge and remove duplicate logging
Address multiple audit findings:

1. config_merge.rs:701 - Gemini merge now initializes env from common
   config when custom config has no env field, ensuring common API keys
   and base URLs are applied correctly.

2. Remove duplicate logging - merge functions now only return warnings,
   callers (live.rs, proxy.rs) handle logging in one place.

3. live.rs:169 - Unify error message style to plain English descriptions
   instead of error codes embedded in messages.

Changes:
- Add fallback to initialize env from common_env when missing
- Remove log::warn! calls from merge functions
- Use descriptive error messages consistently
2026-01-26 17:13:38 +08:00
YoVinchen d5d7c87fd6 fix(dialog): support wrapped format for Gemini common config
Address audit finding: EditProviderDialog.tsx:121 only parsed flat JSON
format, not the wrapped {"env": {...}} format.

Changes:
- Detect if common config is wrapped or flat format
- Extract env object from either format
- Properly compute difference for live settings extraction

This ensures backward compatibility with existing wrapped-format data.
2026-01-26 17:13:07 +08:00
YoVinchen 1e1080c813 fix(form): use JSON parsing for Gemini common config snippet
Address audit finding: ProviderForm.tsx:944 was using envStringToObj()
(KEY=VALUE format) to parse Gemini common config, but it's stored as JSON.

Changes:
- Add parseGeminiCommonConfig() helper function
- Support both flat {"KEY": "VALUE"} and wrapped {"env": {...}} formats
- Properly extract difference between custom and common env

This prevents common config values from being incorrectly saved as
custom config when the formats don't match.
2026-01-26 17:11:18 +08:00
YoVinchen e393dda68e fix(form): add type guard for Gemini env values
Address code audit finding #10:

Replace unsafe type assertion with proper type guard when extracting
custom env config. Convert all values to strings explicitly using
String() to prevent runtime type errors when env contains non-string
values (numbers, booleans, etc).

Before: envObj = customConfig as Record<string, string>
After: Iterate and convert each value with String(value)
2026-01-26 15:55:58 +08:00
YoVinchen 0ecf6891c0 refactor(hooks): improve error handling and user feedback
Address code audit findings #3, #5, #9:

Save queue improvements:
- Add error logging in enqueueSave() catch handlers
- Use console.error with [SaveQueue] prefix for debugging
- Prevent silent failures in async save operations

Extract operation improvements:
- Add toast.success() notification after extract completes
- Notify user that custom config was automatically updated
- Add error logging for failed settingsConfig updates

I18n improvements:
- Replace MCP i18n keys with dedicated Codex keys:
  - codexConfig.tomlFormatError
  - codexConfig.tomlSyntaxError
  - codexConfig.extractedTomlInvalid
- Avoid namespace pollution between modules
2026-01-26 15:55:40 +08:00
YoVinchen cfab768f95 fix(dialog): unify Gemini common config format parsing
Address code audit finding #4:

The frontend was incorrectly parsing Gemini common config as KEY=VALUE
format while the backend expects JSON format. Unify to use JSON parsing
{"KEY": "VALUE"} which matches the format stored by useGeminiCommonConfig.

Also add type guard when converting to string record to prevent type
assertion issues with non-string values.
2026-01-26 15:29:19 +08:00
YoVinchen 73fea48049 refactor(config): extract common config merge to shared function
Address code audit findings #1, #2, #6, #7, #11:

- Extract merge logic to merge_config_for_live() in config_merge.rs
- Add is_common_config_enabled() helper function
- Add MergeResult struct with config and optional warning
- Update live.rs and proxy.rs to use the shared function
- Add logging for JSON parse failures instead of silent fallback
- Convert Chinese error messages to error codes (CODEX_CONFIG_*)
- Support both flat and wrapped formats for Gemini common config

This eliminates code duplication between live.rs and proxy.rs,
making future maintenance easier and preventing behavioral drift.
2026-01-26 15:25:13 +08:00
YoVinchen e54e4d47ae feat(dialog): extract custom config from live settings on edit
Update EditProviderDialog to extract custom configuration from live
settings when common config is enabled, ensuring the edit form shows
only the provider-specific values.

When loading live settings for editing:
- Check if common config is enabled for the provider and app type
- Fetch the common config snippet from database
- Extract the difference (custom config) from live settings
- Handle all three formats: Claude (JSON), Codex (TOML), Gemini (env)

This prevents the merged common config values from appearing in the
edit form, which would cause them to be saved as custom config and
result in duplicate values after the next merge.
2026-01-26 14:12:42 +08:00
YoVinchen 403c3f0690 feat(form): extract custom config difference on provider save
Update ProviderForm to extract only the custom (provider-specific)
configuration when saving, removing common config values that would
be merged at runtime.

Changes:
- Import configMerge utilities (extractDifference, extractTomlDifference)
- Pass finalConfig from hooks to config editor components
- On save, extract difference between current config and common snippet
- Apply extraction for all three app types (Claude JSON, Codex TOML, Gemini env)

This ensures the database stores only the provider's unique configuration,
while the common config is stored separately and merged at runtime when
writing to live files.
2026-01-26 14:11:13 +08:00
YoVinchen b3b3c0732a feat(ui): add merge preview to common config editor components
Add real-time merge preview functionality to all config editor components
(Claude, Codex, Gemini) that shows the final merged configuration when
common config is enabled.

UI changes:
- Add finalConfig/finalEnv prop to display merged result
- Add toggle button to show/hide merge preview (Eye/EyeOff icons)
- Auto-show preview when common config is enabled
- Display custom config editor with label when preview is visible
- Show read-only merged preview below custom config
- Add visual indicators: "只读" badge, green hint text

The preview helps users understand how their custom configuration
combines with the shared common config template before saving.
2026-01-26 14:09:39 +08:00
YoVinchen 2ff329f6c0 refactor(hooks): redesign common config hooks for runtime merge architecture
Refactor all three common config hooks (Claude, Codex, Gemini) to support
the new runtime merge architecture where:
- settingsConfig stores only custom (provider-specific) configuration
- commonConfigSnippet stores shared template configuration in database
- finalConfig is computed at runtime: merge(commonConfig, customConfig)
- Toggling common config only changes enabled state, not settingsConfig

Key changes across all hooks:
- Add finalConfig/finalEnv return value for merge preview
- Use configMerge utilities (computeFinalConfig, extractDifference)
- Remove direct config manipulation on toggle (no more inject/remove)
- Add proper TypeScript return type interfaces
- Simplify state management by removing unnecessary refs
- Improve code organization with clear section comments

This refactor enables:
- Clean separation between custom and common configuration
- Real-time merge preview in the UI
- Consistent behavior across Claude (JSON), Codex (TOML), and Gemini (env)
2026-01-26 14:09:13 +08:00
YoVinchen 5817c9aa77 feat(editor): add readOnly mode to JsonEditor component
Add a readOnly prop to the JsonEditor component that:
- Enables CodeMirror's EditorState.readOnly extension
- Applies visual styling (reduced opacity, default cursor)
- Prevents user modifications to the editor content

This feature is needed for displaying merged config previews where
users should see the final result but not edit it directly.
2026-01-26 14:08:34 +08:00
YoVinchen 6425826e66 fix(deeplink): disable common config for deeplink imported providers
Set common_config_enabled to false by default for providers imported
via deeplink URLs. This prevents unexpected configuration merging when
users import providers from external sources.

Deeplink imported providers should use their settings_config directly
without merging with the global common config snippet, as the imported
configuration is expected to be complete and self-contained.

Changes:
- Always return ProviderMeta with common_config_enabled = false
- Refactor build_provider_meta to handle usage_script conditionally
- Add documentation explaining the design decision
2026-01-26 14:08:00 +08:00
YoVinchen 2f18764490 feat(proxy): add common config merge to proxy live config restore
Extend the proxy service's restore_live_config function to support
common config runtime merge when restoring live configuration after
proxy takeover ends.

This ensures that when the proxy releases control and restores the
original provider configuration, the common config merge is applied
consistently with the main provider switching logic.

The merge follows the same pattern as live.rs:
- Check common_config_enabled flag per app type
- Merge common snippet with provider's custom config
- customConfig overrides commonConfig
2026-01-26 14:07:37 +08:00
YoVinchen 7ad5a76c7a feat(provider): add common config runtime merge to live config sync
Implement runtime merge of common config snippet with provider's custom
config when writing to live configuration files. This enables shared
configuration templates across providers while preserving provider-specific
overrides.

Key changes:
- Add write_live_snapshot_with_merge() function that performs runtime merge
- Support JSON merge for Claude, TOML merge for Codex, and env merge for Gemini
- Update sync_current_to_live() to use the new merge function
- Update provider switch and update operations to use merge function
- Merge rule: customConfig (provider-specific) overrides commonConfig (shared)

The merge is only performed when common_config_enabled is true for the
provider and the corresponding app type.
2026-01-26 14:07:05 +08:00
YoVinchen b1446d0227 feat(config): add core utilities for config merge and difference extraction
Add comprehensive config merge utilities for both Rust backend and TypeScript frontend
to support the new common config architecture where customConfig overrides commonConfig.

Backend (Rust):
- Add config_merge.rs module with JSON and TOML merge functions
- Support deep merge where source overrides target for nested objects
- Add extract_difference functions to identify custom-only keys
- Include compute_final_*_config functions for runtime merge calculation
- Register module in lib.rs

Frontend (TypeScript):
- Add configMerge.ts with isPlainObject, deepClone, deepEqual, deepMerge utilities
- Implement computeFinalConfig for merging common + custom configs
- Add extractDifference to identify keys unique to live config
- Add tomlConfigMerge.ts with TOML-specific merge/extract functions
- Use smol-toml for parsing and serialization

These utilities form the foundation for the refactored common config system
where providers store only custom config and merge with shared templates at runtime.
2026-01-26 13:57:48 +08:00
YoVinchen 3da25e59cd Merge branch 'main' into fix/common-config-sync-improvements 2026-01-26 01:44:37 +08:00
YoVinchen 0938bd5e41 Merge branch 'main' into fix/common-config-sync-improvements 2026-01-25 13:39:38 +08:00
YoVinchen 62523ee17e refactor(config): improve TOML common config merge without markers
- Remove cc-switch:common-config:start/end marker injection
- Allow table definitions in common config snippets
- Add intelligent TOML structure parsing and merging
- Keep legacy marker cleanup for backward compatibility
2026-01-25 04:38:38 +08:00
YoVinchen 0e75193e84 chore: update Cargo.lock and format providerConfigUtils 2026-01-25 03:46:45 +08:00
YoVinchen 078a3a6a53 feat(backend): add CommonConfigEnabledByApp to ProviderMeta
- Add CommonConfigEnabledByApp struct for per-app tracking
- Add common_config_enabled and common_config_enabled_by_app to ProviderMeta
- Add is_empty() method to CommonConfigSnippets
- Skip serializing empty common_config_snippets in config.json
2026-01-25 03:46:22 +08:00
YoVinchen d52f8855ea fix(form): add common config loading check and meta persistence
- Check common config loading state before submit
- Add validation for common config errors on submit
- Add TOML validation for Codex before submit
- Persist commonConfigEnabledByApp to provider meta
- Pass currentProviderId to common config hooks for sync skip
2026-01-25 03:45:51 +08:00
YoVinchen 628a659c0e refactor(hooks): improve config state hooks for codex and gemini
- Improve type safety in config state management
- Better error handling for config parsing
2026-01-25 03:45:19 +08:00
YoVinchen 86cfa452e9 feat(types): add CommonConfigEnabledByApp type for per-app tracking
- Add CommonConfigEnabledByApp type for claude/codex/gemini
- Add commonConfigEnabled field to ProviderMeta
- Add commonConfigEnabledByApp field with higher priority
2026-01-25 03:44:30 +08:00
YoVinchen 06d69caf79 feat(i18n): add common config sync related translations
- Add providerForm.commonConfigLoading key
- Add providerForm.commonConfigSyncFailed key
- Support zh/en/ja languages
2026-01-25 03:43:07 +08:00
YoVinchen 58f7be1517 fix(gemini): add clearing sync and save queue for common config
- Trigger sync when clearing common config to remove from all providers
- Add saveSequenceRef and enqueueSave for ordered async saves
- Add toast notification for sync failures
- Prevent stale sync operations with sequence ID check
2026-01-25 03:42:52 +08:00
YoVinchen 5d48dd7908 fix(codex): add TOML validation and clearing sync for common config
- Add TomlValidationErrorCode type for structured error handling
- Add TOML syntax validation in getSnippetApplyError
- Trigger sync when clearing common config
- Add saveSequenceRef and enqueueSave for ordered async saves
- Add toast notification for sync failures
2026-01-25 03:42:32 +08:00
YoVinchen 0e7e04581d fix(claude): add clearing sync and save queue for common config
- Trigger sync when clearing common config to remove from all providers
- Add saveSequenceRef and enqueueSave for ordered async saves
- Add toast notification for sync failures
- Prevent stale sync operations with sequence ID check
2026-01-25 03:41:29 +08:00
YoVinchen 788e138ece fix(utils): add type validation and error codes for config utilities
- Add isPlainObject validation in replaceGeminiCommonConfigSnippet
- Return error codes instead of hardcoded Chinese strings
- Add CONFIG_NOT_OBJECT, ENV_NOT_OBJECT, COMMON_CONFIG_JSON_INVALID codes
- Use TOML marker constants to avoid hardcoded strings
2026-01-25 03:39:02 +08:00
YoVinchen b692bb4053 fix(sync): add in-flight lock and callback support for common config sync
- Add SyncResult interface for structured sync results
- Add SyncResultCallback type for async notification
- Add syncInFlight lock per appType to prevent concurrent syncs
- Add executeSyncWithLock function for concurrency control
- Add meta fallback detection with automatic backfill
- Improve error handling with detailed error messages
2026-01-25 03:38:45 +08:00
113 changed files with 11833 additions and 1951 deletions
+10 -3
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.jpg)](https://bit.ly/3Nue8mA)
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.1 is an open-source, SOTA model built for real-world development and agentic workflows. It delivers top-tier performance on major coding benchmarks such as SWE, VIBE, and Multi-SWE. Powered by a 10B active / 230B total MoE architecture, M2.1 enables faster inference, easier deployment, and even local execution. It excels at coding, navigating digital environments, and handling long, multi-step tasks at scale.
[Click](https://bit.ly/3Nue8mA) 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>
@@ -48,6 +50,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<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>
+9 -2
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.jpg)](https://bit.ly/3Nue8mA)
本プロジェクトは 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.1 は、実務開発とエージェントワークフロー向けに構築されたオープンソースの最先端モデルです。100 億のアクティブパラメータ / 2,300 億の総パラメータを持つ MoE アーキテクチャにより、高速な推論、簡単なデプロイ、ローカル実行にも対応します。SWE、VIBE、Multi-SWE などの主要コーディングベンチマークでトップクラスの性能を発揮し、コーディング、デジタル環境のナビゲーション、大規模な多段階タスクの処理に優れています
[こちら](https://bit.ly/3Nue8mA)から MiniMax Coding Plan の限定 12% オフを入手!
---
@@ -48,6 +50,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<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>
+10 -3
View File
@@ -15,16 +15,18 @@
## ❤️赞助商
[![智谱 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.x 系列模型是面向实际开发与智能体工作流打造的编码模型,M2.1 基于 100 亿激活 / 2300 亿总参的混合专家架构打造,推理更快、部署更便捷且支持本地运行,在 SWE、VIBE、Multi-SWE 等主流代码评测基准中均表现顶尖,擅长代码开发、数字环境适配及规模化处理长链路多步骤任务
[点击](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>
@@ -49,6 +51,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
</tr>
<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 等模型的中转服务。主打<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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

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

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+1
View File
@@ -57,6 +57,7 @@
"@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",
+39
View File
@@ -59,6 +59,9 @@ 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)
@@ -1048,6 +1051,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:
@@ -3890,6 +3906,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)
+2
View File
@@ -746,6 +746,7 @@ dependencies = [
"tower-http 0.5.2",
"url",
"uuid",
"webkit2gtk",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -5633,6 +5634,7 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
dependencies = [
"indexmap 2.11.4",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
+4 -1
View File
@@ -35,7 +35,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml = { version = "0.8", features = ["preserve_order"] }
toml_edit = "0.22"
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
@@ -65,6 +65,9 @@ uuid = { version = "1.11", features = ["v4"] }
[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"
+17 -1
View File
@@ -339,6 +339,20 @@ pub struct CommonConfigSnippets {
}
impl CommonConfigSnippets {
/// 检查是否所有字段都为空
pub fn is_empty(&self) -> bool {
let is_blank = |value: &Option<String>| {
value
.as_ref()
.map(|snippet| snippet.trim().is_empty())
.unwrap_or(true)
};
is_blank(&self.claude)
&& is_blank(&self.codex)
&& is_blank(&self.gemini)
&& is_blank(&self.opencode)
}
/// 获取指定应用的通用配置片段
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
@@ -378,7 +392,9 @@ pub struct MultiAppConfig {
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
#[serde(default)]
/// 注意:此字段主要用于从旧版 config.json 迁移数据到数据库
/// 迁移成功后会被清空,空时不写入 config.json
#[serde(default, skip_serializing_if = "CommonConfigSnippets::is_empty")]
pub common_config_snippets: CommonConfigSnippets,
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
#[serde(default, skip_serializing_if = "Option::is_none")]
+27 -2
View File
@@ -204,11 +204,36 @@ pub async fn set_common_config_snippet(
) -> Result<(), String> {
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" | "omo" => {
"claude" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"gemini" => {
// 验证 ENV/JSON 格式并拒绝禁用键
let validation = crate::config_merge::validate_gemini_common_snippet(&snippet);
// 如果有禁用键,返回错误
if !validation.forbidden_keys_found.is_empty() {
return Err(format!(
"GEMINI_FORBIDDEN_KEYS:{}",
validation.forbidden_keys_found.join(",")
));
}
// 如果非空但解析后无有效内容,返回错误
if !validation.is_valid {
return Err("GEMINI_INVALID_SNIPPET".to_string());
}
}
"codex" => {
// TOML 格式,暂不验证(前端验证)
}
"omo" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {}
_ => {}
}
}
+1 -1
View File
@@ -323,7 +323,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
let mut search_paths: Vec<std::path::PathBuf> = vec![
home.join(".local/bin"), // Native install (official recommended)
home.join(".npm-global/bin"),
home.join("n/bin"), // n version manager
home.join("n/bin"), // n version manager
home.join(".volta/bin"), // Volta package manager
];
+889
View File
@@ -0,0 +1,889 @@
//! Configuration merge utilities for the common config redesign.
//!
//! This module provides functions for:
//! - `compute_final_config`: Merges common config (base) with custom config (override)
//! - `extract_difference`: Extracts custom parts from live config by comparing with common config
//!
//! Supports JSON (Claude, Gemini) and TOML (Codex) formats.
use serde_json::{Map, Value as JsonValue};
use toml::Value as TomlValue;
// ============================================================================
// JSON Configuration Merge Functions
// ============================================================================
/// Deep merge two JSON objects where `source` overrides `target`.
///
/// Merge rules:
/// - Nested objects: Recursive merge
/// - Arrays: Source completely replaces target (no element-level merge)
/// - Primitives: Source overrides target
/// - Null values: Do not override
fn deep_merge_json(target: &mut JsonValue, source: &JsonValue) {
// First check if both are objects without destructuring
let both_objects =
matches!(target, JsonValue::Object(_)) && matches!(source, JsonValue::Object(_));
if both_objects {
// Safe to destructure now since we know both are objects
if let (JsonValue::Object(target_map), JsonValue::Object(source_map)) = (target, source) {
for (key, source_value) in source_map {
if source_value.is_null() {
// Null doesn't override
continue;
}
match target_map.get_mut(key) {
Some(target_value) if target_value.is_object() && source_value.is_object() => {
// Nested object: recursive merge
deep_merge_json(target_value, source_value);
}
_ => {
// Other cases: source overrides
target_map.insert(key.clone(), source_value.clone());
}
}
}
}
} else {
// Non-object: source overrides
*target = source.clone();
}
}
/// Compute final JSON config.
///
/// Common config as base, custom config overrides (custom takes priority).
///
/// # Arguments
/// * `custom_config` - Provider's custom configuration
/// * `common_config` - Common configuration snippet
/// * `enabled` - Whether common config is enabled
///
/// # Returns
/// The merged final configuration as JSON value
pub fn compute_final_json_config(
custom_config: &JsonValue,
common_config: &JsonValue,
enabled: bool,
) -> JsonValue {
if !enabled {
return custom_config.clone();
}
// Validate both are objects
let common_obj = match common_config {
JsonValue::Object(m) if !m.is_empty() => m,
_ => return custom_config.clone(),
};
let custom_obj = match custom_config {
JsonValue::Object(_) => custom_config,
_ => return custom_config.clone(),
};
// Start with common config as base
let mut result = JsonValue::Object(common_obj.clone());
// Merge custom config on top (custom overrides common)
deep_merge_json(&mut result, custom_obj);
result
}
/// Check if two JSON values are deeply equal.
fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool {
match (a, b) {
(JsonValue::Null, JsonValue::Null) => true,
(JsonValue::Bool(a), JsonValue::Bool(b)) => a == b,
(JsonValue::Number(a), JsonValue::Number(b)) => a == b,
(JsonValue::String(a), JsonValue::String(b)) => a == b,
(JsonValue::Array(a), JsonValue::Array(b)) => {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| json_deep_equal(x, y))
}
(JsonValue::Object(a), JsonValue::Object(b)) => {
if a.len() != b.len() {
return false;
}
a.iter()
.all(|(k, v)| b.get(k).is_some_and(|bv| json_deep_equal(v, bv)))
}
_ => false,
}
}
/// Extract difference between live config and common config.
///
/// Extraction rules:
/// - Keys not in common config → include in custom config
/// - Keys in common config but with different values → include in custom config (user override)
/// - Keys in common config with same values → skip (avoid redundant storage)
///
/// # Arguments
/// * `live_config` - Configuration read from live file
/// * `common_config` - Common configuration snippet
///
/// # Returns
/// Tuple of (custom_config, has_common_keys)
pub fn extract_json_difference(
live_config: &JsonValue,
common_config: &JsonValue,
) -> (JsonValue, bool) {
let live_obj = match live_config {
JsonValue::Object(m) => m,
_ => return (live_config.clone(), false),
};
let common_obj = match common_config {
JsonValue::Object(m) => m,
_ => return (live_config.clone(), false),
};
let mut custom_config = Map::new();
let mut has_common_keys = false;
fn extract_recursive(
live: &Map<String, JsonValue>,
common: &Map<String, JsonValue>,
target: &mut Map<String, JsonValue>,
has_common: &mut bool,
) {
for (key, live_value) in live {
match common.get(key) {
None => {
// Case 1: Key not in common config, keep it
target.insert(key.clone(), live_value.clone());
}
Some(common_value) => {
// Check if both are objects for nested handling
match (live_value, common_value) {
(JsonValue::Object(live_map), JsonValue::Object(common_map)) => {
// Case 2: Nested object, recurse
let mut nested = Map::new();
extract_recursive(live_map, common_map, &mut nested, has_common);
if !nested.is_empty() {
target.insert(key.clone(), JsonValue::Object(nested));
} else {
// Nested object matches common config
*has_common = true;
}
}
_ if !json_deep_equal(live_value, common_value) => {
// Case 3: Value different, keep it (user override)
target.insert(key.clone(), live_value.clone());
}
_ => {
// Case 4: Value same, skip (avoid redundancy)
*has_common = true;
}
}
}
}
}
}
extract_recursive(
live_obj,
common_obj,
&mut custom_config,
&mut has_common_keys,
);
(JsonValue::Object(custom_config), has_common_keys)
}
// ============================================================================
// TOML Configuration Merge Functions
// ============================================================================
/// Deep merge TOML tables while preserving target's key order (custom first, then common).
///
/// - Keeps target's existing values (target = custom config has priority)
/// - Only adds keys from source that don't exist in target (common-only keys)
/// - For nested tables, recursively merges while preserving target's keys first
fn deep_merge_toml_preserve_order(target: &mut TomlValue, source: &TomlValue) {
let both_tables =
matches!(target, TomlValue::Table(_)) && matches!(source, TomlValue::Table(_));
if both_tables {
if let (TomlValue::Table(target_map), TomlValue::Table(source_map)) = (target, source) {
for (key, source_value) in source_map {
match target_map.get_mut(key) {
Some(target_value) if target_value.is_table() && source_value.is_table() => {
// Nested table: recursive merge (preserving target's keys first)
deep_merge_toml_preserve_order(target_value, source_value);
}
Some(_) => {
// Key exists in target: keep target's value (custom overrides common)
}
None => {
// Key only in source: add to target (common-only keys appear after)
target_map.insert(key.clone(), source_value.clone());
}
}
}
}
}
// Non-table case: keep target as-is (custom has priority)
}
/// Compute final TOML config.
///
/// # Arguments
/// * `custom_config` - Provider's custom TOML configuration
/// * `common_config` - Common TOML configuration snippet
/// * `enabled` - Whether common config is enabled
///
/// # Returns
/// Tuple of (final_config_toml, error_message)
pub fn compute_final_toml_config_str(
custom_toml: &str,
common_toml: &str,
enabled: bool,
) -> (String, Option<String>) {
if !enabled || common_toml.trim().is_empty() {
return (custom_toml.to_string(), None);
}
// Check if common TOML has actual content (not just comments)
let common_has_content = common_toml.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
});
if !common_has_content {
return (custom_toml.to_string(), None);
}
// Parse custom TOML
let custom_config: TomlValue = match custom_toml.parse() {
Ok(v) => v,
Err(_) if custom_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
Err(e) => {
return (
custom_toml.to_string(),
Some(format!("Failed to parse custom TOML: {e}")),
)
}
};
// Parse common TOML
let common_config: TomlValue = match common_toml.parse() {
Ok(v) => v,
Err(e) => {
return (
custom_toml.to_string(),
Some(format!("Failed to parse common TOML: {e}")),
)
}
};
// Start with custom config (so custom keys appear first in output)
let mut result = custom_config;
// Add common config keys that don't exist in custom (these appear after custom keys)
// Custom values always take priority (they're already in result)
deep_merge_toml_preserve_order(&mut result, &common_config);
// Serialize back to TOML string
match toml::to_string_pretty(&result) {
Ok(s) => (s, None),
Err(e) => (
custom_toml.to_string(),
Some(format!("Failed to serialize TOML: {e}")),
),
}
}
/// Check if two TOML values are deeply equal.
fn toml_deep_equal(a: &TomlValue, b: &TomlValue) -> bool {
match (a, b) {
(TomlValue::String(a), TomlValue::String(b)) => a == b,
(TomlValue::Integer(a), TomlValue::Integer(b)) => a == b,
(TomlValue::Float(a), TomlValue::Float(b)) => (a - b).abs() < f64::EPSILON,
(TomlValue::Boolean(a), TomlValue::Boolean(b)) => a == b,
(TomlValue::Datetime(a), TomlValue::Datetime(b)) => a == b,
(TomlValue::Array(a), TomlValue::Array(b)) => {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| toml_deep_equal(x, y))
}
(TomlValue::Table(a), TomlValue::Table(b)) => {
if a.len() != b.len() {
return false;
}
a.iter()
.all(|(k, v)| b.get(k).is_some_and(|bv| toml_deep_equal(v, bv)))
}
_ => false,
}
}
/// Extract difference between live TOML config and common config.
///
/// # Returns
/// Tuple of (custom_toml, has_common_keys, error_message)
pub fn extract_toml_difference_str(
live_toml: &str,
common_toml: &str,
) -> (String, bool, Option<String>) {
if common_toml.trim().is_empty() {
return (live_toml.to_string(), false, None);
}
// Check if common TOML has actual content
let common_has_content = common_toml.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
});
if !common_has_content {
return (live_toml.to_string(), false, None);
}
// Parse live TOML
let live_config: TomlValue = match live_toml.parse() {
Ok(v) => v,
Err(_) if live_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
Err(e) => {
return (
live_toml.to_string(),
false,
Some(format!("Failed to parse live TOML: {e}")),
)
}
};
// Parse common TOML
let common_config: TomlValue = match common_toml.parse() {
Ok(v) => v,
Err(e) => {
return (
live_toml.to_string(),
false,
Some(format!("Failed to parse common TOML: {e}")),
)
}
};
let live_table = match &live_config {
TomlValue::Table(m) => m,
_ => return (live_toml.to_string(), false, None),
};
let common_table = match &common_config {
TomlValue::Table(m) => m,
_ => return (live_toml.to_string(), false, None),
};
let mut custom_table = toml::map::Map::new();
let mut has_common_keys = false;
fn extract_recursive_toml(
live: &toml::map::Map<String, TomlValue>,
common: &toml::map::Map<String, TomlValue>,
target: &mut toml::map::Map<String, TomlValue>,
has_common: &mut bool,
) {
for (key, live_value) in live {
match common.get(key) {
None => {
target.insert(key.clone(), live_value.clone());
}
Some(common_value) => match (live_value, common_value) {
(TomlValue::Table(live_map), TomlValue::Table(common_map)) => {
let mut nested = toml::map::Map::new();
extract_recursive_toml(live_map, common_map, &mut nested, has_common);
if !nested.is_empty() {
target.insert(key.clone(), TomlValue::Table(nested));
} else {
*has_common = true;
}
}
_ if !toml_deep_equal(live_value, common_value) => {
target.insert(key.clone(), live_value.clone());
}
_ => {
*has_common = true;
}
},
}
}
}
extract_recursive_toml(
live_table,
common_table,
&mut custom_table,
&mut has_common_keys,
);
let custom_config = TomlValue::Table(custom_table);
match toml::to_string_pretty(&custom_config) {
Ok(s) if s.trim().is_empty() => (String::new(), has_common_keys, None),
Ok(s) => (s, has_common_keys, None),
Err(e) => (
live_toml.to_string(),
false,
Some(format!("Failed to serialize TOML: {e}")),
),
}
}
// ============================================================================
// Live Config Merge for Provider Sync
// ============================================================================
use crate::app_config::AppType;
use crate::provider::{Provider, ProviderMeta};
/// Result of merging common config with provider's custom config.
#[derive(Debug)]
pub struct MergeResult {
/// The final merged configuration
pub config: JsonValue,
/// Warning message if any (e.g., parse errors that were recovered from)
pub warning: Option<String>,
}
/// Check if common config is enabled for a provider and app type.
///
/// Priority:
/// 1. `meta.common_config_enabled_by_app.{app_type}` (per-app setting)
/// 2. `meta.common_config_enabled` (global setting)
/// 3. `false` (default)
pub fn is_common_config_enabled(meta: Option<&ProviderMeta>, app_type: &AppType) -> bool {
meta.and_then(|m| {
m.common_config_enabled_by_app
.as_ref()
.and_then(|by_app| match app_type {
AppType::Claude => by_app.claude,
AppType::Codex => by_app.codex,
AppType::Gemini => by_app.gemini,
AppType::OpenCode => by_app.opencode,
})
.or(m.common_config_enabled)
})
.unwrap_or(false)
}
/// Merge common config with provider's custom config for live file writing.
///
/// This is the single source of truth for common config merging logic.
/// Used by both `live.rs` and `proxy.rs`.
///
/// # Arguments
/// * `app_type` - The application type (Claude, Codex, Gemini, OpenCode)
/// * `provider` - The provider whose config is being merged
/// * `common_snippet` - The common config snippet from database (may be empty)
///
/// # Returns
/// `MergeResult` containing the final config and optional warning
pub fn merge_config_for_live(
app_type: &AppType,
provider: &Provider,
common_snippet: Option<&str>,
) -> MergeResult {
// Check if common config is enabled
let enabled = is_common_config_enabled(provider.meta.as_ref(), app_type);
// If not enabled or snippet is empty, return original config
let snippet = match common_snippet {
Some(s) if enabled && !s.trim().is_empty() => s,
_ => {
return MergeResult {
config: provider.settings_config.clone(),
warning: None,
}
}
};
// Perform merge based on app type
match app_type {
AppType::Claude => merge_claude_config(&provider.settings_config, snippet),
AppType::Codex => merge_codex_config(&provider.settings_config, snippet),
AppType::Gemini => merge_gemini_config(&provider.settings_config, snippet),
AppType::OpenCode => {
// OpenCode doesn't support common config merge
MergeResult {
config: provider.settings_config.clone(),
warning: None,
}
}
}
}
/// Merge Claude config (JSON format).
fn merge_claude_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
let common_value: JsonValue = match serde_json::from_str(common_snippet) {
Ok(v) => v,
Err(e) => {
// Return warning without logging - caller will log if needed
return MergeResult {
config: custom_config.clone(),
warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")),
};
}
};
MergeResult {
config: compute_final_json_config(custom_config, &common_value, true),
warning: None,
}
}
/// Merge Codex config (TOML for config field, JSON for auth field).
fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
let mut merged_config = custom_config.clone();
let mut warning = None;
if let Some(obj) = merged_config.as_object_mut() {
if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) {
let (merged_toml, error) =
compute_final_toml_config_str(config_str, common_snippet, true);
if let Some(e) = error {
// Return warning without logging - caller will log if needed
warning = Some(format!("CODEX_TOML_MERGE_ERROR: {e}"));
}
obj.insert("config".to_string(), JsonValue::String(merged_toml));
}
}
MergeResult {
config: merged_config,
warning,
}
}
/// Merge Gemini config (JSON format for env field).
///
/// Gemini common config can be stored in three formats:
/// - ENV format: KEY=VALUE lines (one per line)
/// - Flat JSON: `{"KEY": "VALUE", ...}`
/// - Wrapped JSON: `{"env": {"KEY": "VALUE", ...}}`
///
/// This function supports all formats for backward compatibility.
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
// Parse and validate common config (filters forbidden keys)
let validation = validate_gemini_common_snippet(common_snippet);
let common_env = validation.env;
// Generate warning if forbidden keys were found
let warning = if !validation.forbidden_keys_found.is_empty() {
Some(format!(
"GEMINI_FORBIDDEN_KEYS:{}",
validation.forbidden_keys_found.join(",")
))
} else {
None
};
if common_env.is_empty() {
return MergeResult {
config: custom_config.clone(),
warning,
};
}
let mut merged_config = custom_config.clone();
// Merge only the env field
// If custom config has env, merge common into it; otherwise initialize with common env
if let Some(merged_obj) = merged_config.as_object_mut() {
if let Some(merged_env) = merged_obj.get_mut("env") {
if let Some(merged_env_obj) = merged_env.as_object_mut() {
// Common env as base, custom env overrides
let mut final_env = common_env;
for (k, v) in merged_env_obj.iter() {
final_env.insert(k.clone(), v.clone());
}
*merged_env = JsonValue::Object(final_env);
}
} else if !common_env.is_empty() {
// Custom config has no env field - initialize with common env
merged_obj.insert("env".to_string(), JsonValue::Object(common_env));
}
}
MergeResult {
config: merged_config,
warning,
}
}
/// Parse Gemini common config snippet supporting multiple formats.
///
/// Formats supported:
/// - ENV format: KEY=VALUE lines
/// - Flat JSON: {"KEY": "VALUE", ...}
/// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<String, JsonValue> {
let trimmed = snippet.trim();
if trimmed.is_empty() {
return serde_json::Map::new();
}
// Try JSON first
if let Ok(parsed) = serde_json::from_str::<JsonValue>(trimmed) {
if let Some(obj) = parsed.as_object() {
// Check if it's wrapped format {"env": {...}}
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
return env_value.clone();
}
// Flat format
return obj.clone();
}
}
// Parse as ENV format (KEY=VALUE lines)
let mut result = serde_json::Map::new();
for line in trimmed.lines() {
let line_trimmed = line.trim();
if line_trimmed.is_empty() || line_trimmed.starts_with('#') {
continue;
}
if let Some(equal_index) = line_trimmed.find('=') {
let key = line_trimmed[..equal_index].trim();
let raw_value = line_trimmed[equal_index + 1..].trim();
// Strip surrounding quotes (single or double) from value
// e.g., KEY="value" or KEY='value' -> value
let value = strip_env_quotes(raw_value);
if !key.is_empty() {
result.insert(key.to_string(), JsonValue::String(value.to_string()));
}
}
}
result
}
/// Gemini common config forbidden keys - these should never be in common config
/// as they are provider-specific credentials/endpoints.
const GEMINI_FORBIDDEN_KEYS: &[&str] = &["GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"];
/// Result of validating Gemini common config snippet
#[derive(Debug, Clone)]
pub struct GeminiSnippetValidation {
/// Parsed environment variables (with forbidden keys filtered out)
pub env: serde_json::Map<String, JsonValue>,
/// List of forbidden keys that were found and filtered
pub forbidden_keys_found: Vec<String>,
/// Whether the snippet is valid (parseable and non-empty after filtering)
pub is_valid: bool,
}
/// Parse and validate Gemini common config snippet.
///
/// This function:
/// 1. Parses the snippet (supports ENV/JSON formats)
/// 2. Filters out forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY)
/// 3. Returns validation result with filtered env and forbidden keys found
pub fn validate_gemini_common_snippet(snippet: &str) -> GeminiSnippetValidation {
let raw_env = parse_gemini_common_snippet(snippet);
let mut filtered_env = serde_json::Map::new();
let mut forbidden_keys_found = Vec::new();
for (key, value) in raw_env {
if GEMINI_FORBIDDEN_KEYS.contains(&key.as_str()) {
forbidden_keys_found.push(key);
} else {
filtered_env.insert(key, value);
}
}
GeminiSnippetValidation {
is_valid: !filtered_env.is_empty() || snippet.trim().is_empty(),
env: filtered_env,
forbidden_keys_found,
}
}
/// Strip surrounding quotes from ENV value.
/// Supports both single and double quotes: "value" -> value, 'value' -> value
fn strip_env_quotes(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &s[1..s.len() - 1];
}
}
s
}
// ============================================================================
// Unit Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_compute_final_json_config_disabled() {
let custom = json!({"a": 1, "b": 2});
let common = json!({"c": 3});
let result = compute_final_json_config(&custom, &common, false);
assert_eq!(result, custom);
}
#[test]
fn test_compute_final_json_config_enabled() {
let custom = json!({"a": 1, "b": 2});
let common = json!({"b": 99, "c": 3});
let result = compute_final_json_config(&custom, &common, true);
// custom overrides common, so b should be 2
assert_eq!(result["a"], 1);
assert_eq!(result["b"], 2); // custom wins
assert_eq!(result["c"], 3); // from common
}
#[test]
fn test_compute_final_json_config_nested() {
let custom = json!({
"env": {
"API_KEY": "custom-key",
"CUSTOM_VAR": "value"
}
});
let common = json!({
"env": {
"API_KEY": "common-key",
"SHARED_VAR": "shared"
},
"includeCoAuthoredBy": false
});
let result = compute_final_json_config(&custom, &common, true);
assert_eq!(result["env"]["API_KEY"], "custom-key"); // custom wins
assert_eq!(result["env"]["CUSTOM_VAR"], "value"); // from custom
assert_eq!(result["env"]["SHARED_VAR"], "shared"); // from common
assert_eq!(result["includeCoAuthoredBy"], false); // from common
}
#[test]
fn test_extract_json_difference() {
let live = json!({
"env": {
"API_KEY": "my-key",
"SHARED_VAR": "shared"
},
"includeCoAuthoredBy": false,
"custom_field": true
});
let common = json!({
"env": {
"SHARED_VAR": "shared"
},
"includeCoAuthoredBy": false
});
let (custom, has_common) = extract_json_difference(&live, &common);
// Should keep API_KEY (not in common) and custom_field
assert_eq!(custom["env"]["API_KEY"], "my-key");
assert_eq!(custom["custom_field"], true);
// Should NOT have SHARED_VAR or includeCoAuthoredBy (same as common)
assert!(custom["env"].get("SHARED_VAR").is_none());
assert!(custom.get("includeCoAuthoredBy").is_none());
assert!(has_common);
}
#[test]
fn test_extract_json_difference_with_override() {
let live = json!({
"includeCoAuthoredBy": true, // Different from common!
"shared": "value"
});
let common = json!({
"includeCoAuthoredBy": false,
"shared": "value"
});
let (custom, has_common) = extract_json_difference(&live, &common);
// Should keep includeCoAuthoredBy because value is different
assert_eq!(custom["includeCoAuthoredBy"], true);
// Should NOT have shared (same as common)
assert!(custom.get("shared").is_none());
assert!(has_common);
}
#[test]
fn test_compute_final_toml_config() {
let custom = r#"
model = "custom-model"
[custom_section]
key = "value"
"#;
let common = r#"
model = "common-model"
shared_key = "shared"
"#;
let (result, error) = compute_final_toml_config_str(custom, common, true);
assert!(error.is_none());
assert!(result.contains("custom-model")); // custom wins
assert!(result.contains("shared_key")); // from common
// With preserve_order feature enabled, verify key ordering via parsing
// instead of relying on string position (which is fragile)
let parsed: TomlValue = result.parse().expect("result should be valid TOML");
let table = parsed.as_table().expect("result should be a table");
let keys: Vec<&String> = table.keys().collect();
// Custom keys should appear before common-only keys
let model_idx = keys.iter().position(|k| *k == "model");
let custom_section_idx = keys.iter().position(|k| *k == "custom_section");
let shared_key_idx = keys.iter().position(|k| *k == "shared_key");
assert!(model_idx.is_some(), "model key should exist in result");
assert!(
custom_section_idx.is_some(),
"custom_section key should exist in result"
);
assert!(
shared_key_idx.is_some(),
"shared_key key should exist in result"
);
// With preserve_order, custom keys (model, custom_section) should come before common-only keys (shared_key)
assert!(
model_idx.unwrap() < shared_key_idx.unwrap(),
"custom 'model' should appear before common-only 'shared_key' (got model_idx={:?}, shared_key_idx={:?})",
model_idx, shared_key_idx
);
}
#[test]
fn test_extract_toml_difference() {
let live = r#"
model = "my-model"
shared_key = "shared"
[custom_section]
key = "value"
"#;
let common = r#"
shared_key = "shared"
"#;
let (custom, has_common, error) = extract_toml_difference_str(live, common);
assert!(error.is_none());
assert!(custom.contains("model")); // not in common
assert!(custom.contains("custom_section")); // not in common
assert!(!custom.contains("shared_key")); // same as common
assert!(has_common);
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ impl Database {
/// 获取整流器配置
///
/// 返回整流器配置,如果不存在则返回默认值(全部启
/// 返回整流器配置,如果不存在则返回默认值(全部启)
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
match self.get_setting("rectifier_config")? {
Some(json) => serde_json::from_str(&json)
+55 -45
View File
@@ -180,58 +180,68 @@ fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
}
/// Build provider meta with usage script configuration
///
/// Note: Deeplink imported providers have common config disabled by default
/// to avoid unexpected configuration merging.
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
// Check if any usage script fields are provided
if request.usage_script.is_none()
&& request.usage_enabled.is_none()
&& request.usage_api_key.is_none()
&& request.usage_base_url.is_none()
&& request.usage_access_token.is_none()
&& request.usage_user_id.is_none()
&& request.usage_auto_interval.is_none()
{
return Ok(None);
}
let has_usage_fields = request.usage_script.is_some()
|| request.usage_enabled.is_some()
|| request.usage_api_key.is_some()
|| request.usage_base_url.is_some()
|| request.usage_access_token.is_some()
|| request.usage_user_id.is_some()
|| request.usage_auto_interval.is_some();
// Decode usage script code if provided
let code = if let Some(script_b64) = &request.usage_script {
let decoded = decode_base64_param("usage_script", script_b64)?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
// Build usage script if fields are provided
let usage_script = if has_usage_fields {
// Decode usage script code if provided
let code = if let Some(script_b64) = &request.usage_script {
let decoded = decode_base64_param("usage_script", script_b64)?;
String::from_utf8(decoded).map_err(|e| {
AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}"))
})?
} else {
String::new()
};
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
Some(UsageScript {
enabled,
language: "javascript".to_string(),
code,
timeout: Some(10),
api_key: request
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
})
} else {
String::new()
};
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Build UsageScript - use provider's API key and endpoint as defaults
// Note: use primary endpoint only (first one if comma-separated)
let usage_script = UsageScript {
enabled,
language: "javascript".to_string(),
code,
timeout: Some(10),
api_key: request
.usage_api_key
.clone()
.or_else(|| request.api_key.clone()),
base_url: request.usage_base_url.clone().or_else(|| {
let primary = get_primary_endpoint(request);
if primary.is_empty() {
None
} else {
Some(primary)
}
}),
access_token: request.usage_access_token.clone(),
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
None
};
// Always return a ProviderMeta with common_config_enabled = false for deeplink imports
// This ensures the imported provider uses its own settings_config directly
// without merging with the global common config snippet
Ok(Some(ProviderMeta {
usage_script: Some(usage_script),
usage_script,
common_config_enabled: Some(false),
..Default::default()
}))
}
+16
View File
@@ -6,6 +6,7 @@ mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod config_merge;
mod database;
mod deeplink;
mod error;
@@ -768,6 +769,21 @@ pub fn run() {
restore_proxy_state_on_startup(&state).await;
});
// 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") {
+26 -1
View File
@@ -191,6 +191,19 @@ pub struct ProviderProxyConfig {
pub proxy_password: Option<String>,
}
/// 通用配置启用状态(按应用)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonConfigEnabledByApp {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gemini: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opencode: Option<bool>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -230,6 +243,18 @@ pub struct ProviderMeta {
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
/// 是否启用通用配置片段(用于跨供应商保持勾选状态)
#[serde(
rename = "commonConfigEnabled",
skip_serializing_if = "Option::is_none"
)]
pub common_config_enabled: Option<bool>,
/// 按应用记录通用配置启用状态(优先于 commonConfigEnabled
#[serde(
rename = "commonConfigEnabledByApp",
skip_serializing_if = "Option::is_none"
)]
pub common_config_enabled_by_app: Option<CommonConfigEnabledByApp>,
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
@@ -631,7 +656,7 @@ pub struct OpenCodeModelLimit {
}
#[cfg(test)]
mod tests {
mod provider_tests {
use super::{
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
ProviderManager, ProviderMeta, UniversalProvider,
+213 -22
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)?
+1
View File
@@ -21,6 +21,7 @@ pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub mod thinking_budget_rectifier;
pub mod thinking_rectifier;
pub(crate) mod types;
pub mod usage;
+36 -2
View File
@@ -97,11 +97,21 @@ impl ModelMapping {
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
body.get("thinking")
match body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled")
{
Some("enabled") | Some("adaptive") => true,
Some("disabled") | None => false,
Some(other) => {
log::warn!(
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
);
false
}
}
}
/// 对请求体应用模型映射
@@ -300,6 +310,30 @@ mod tests {
assert!(mapped.is_none());
}
#[test]
fn test_thinking_adaptive() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "adaptive"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_unknown_type() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "some_future_type"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
@@ -0,0 +1,359 @@
//! Thinking Budget 整流器
//!
//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。
//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。
use super::types::RectifierConfig;
use serde_json::Value;
/// 最大 thinking budget tokens
const MAX_THINKING_BUDGET: u64 = 32000;
/// 最大 max_tokens 值
const MAX_TOKENS_VALUE: u64 = 64000;
/// max_tokens 必须大于 budget_tokens
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
/// Budget 整流结果
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetRectifySnapshot {
/// max_tokens
pub max_tokens: Option<u64>,
/// thinking.type
pub thinking_type: Option<String>,
/// thinking.budget_tokens
pub thinking_budget_tokens: Option<u64>,
}
/// Budget 整流结果
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetRectifyResult {
/// 是否应用了整流
pub applied: bool,
/// 整流前快照
pub before: BudgetRectifySnapshot,
/// 整流后快照
pub after: BudgetRectifySnapshot,
}
/// 检测是否需要触发 thinking budget 整流器
///
/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束
pub fn should_rectify_thinking_budget(
error_message: Option<&str>,
config: &RectifierConfig,
) -> bool {
// 检查总开关
if !config.enabled {
return false;
}
// 检查子开关
if !config.request_thinking_budget {
return false;
}
let Some(msg) = error_message else {
return false;
};
let lower = msg.to_lowercase();
// 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发
let has_budget_tokens_reference =
lower.contains("budget_tokens") || lower.contains("budget tokens");
let has_thinking_reference = lower.contains("thinking");
let has_1024_constraint = lower.contains("greater than or equal to 1024")
|| lower.contains(">= 1024")
|| (lower.contains("1024") && lower.contains("input should be"));
if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {
return true;
}
false
}
/// 对请求体执行 budget 整流
///
/// 整流动作:
/// - `thinking.type = "enabled"`
/// - `thinking.budget_tokens = 32000`
/// - 如果 `max_tokens < 32001`,设为 `64000`
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
let before = snapshot_budget(body);
// 与 CCH 对齐:adaptive 请求不改写
if before.thinking_type.as_deref() == Some("adaptive") {
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
}
// 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流
if !body.get("thinking").is_some_and(Value::is_object) {
body["thinking"] = Value::Object(serde_json::Map::new());
}
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
return BudgetRectifyResult {
applied: false,
before: before.clone(),
after: before,
};
};
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
thinking.insert(
"budget_tokens".to_string(),
Value::Number(MAX_THINKING_BUDGET.into()),
);
if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) {
body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into());
}
let after = snapshot_budget(body);
BudgetRectifyResult {
applied: before != after,
before,
after,
}
}
fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {
let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64());
let thinking = body.get("thinking").and_then(|t| t.as_object());
let thinking_type = thinking
.and_then(|t| t.get("type"))
.and_then(|v| v.as_str())
.map(ToString::to_string);
let thinking_budget_tokens = thinking
.and_then(|t| t.get("budget_tokens"))
.and_then(|v| v.as_u64());
BudgetRectifySnapshot {
max_tokens,
thinking_type,
thinking_budget_tokens,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn enabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
fn budget_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: false,
}
}
fn master_disabled_config() -> RectifierConfig {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
// ==================== should_rectify_thinking_budget 测试 ====================
#[test]
fn test_detect_budget_tokens_thinking_error() {
assert!(should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_max_tokens_error() {
assert!(!should_rectify_thinking_budget(
Some("budget_tokens must be less than max_tokens"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_1024_error() {
assert!(!should_rectify_thinking_budget(
Some("budget_tokens: value must be at least 1024"),
&enabled_config()
));
}
#[test]
fn test_detect_budget_tokens_with_thinking_and_1024_error() {
assert!(should_rectify_thinking_budget(
Some("thinking budget_tokens must be >= 1024"),
&enabled_config()
));
}
#[test]
fn test_no_trigger_for_unrelated_error() {
assert!(!should_rectify_thinking_budget(
Some("Request timeout"),
&enabled_config()
));
assert!(!should_rectify_thinking_budget(None, &enabled_config()));
}
#[test]
fn test_disabled_budget_config() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&budget_disabled_config()
));
}
#[test]
fn test_master_disabled() {
assert!(!should_rectify_thinking_budget(
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
&master_disabled_config()
));
}
// ==================== rectify_thinking_budget 测试 ====================
#[test]
fn test_rectify_budget_basic() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(result.before.thinking_budget_tokens, Some(512));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.before.max_tokens, Some(1024));
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_skips_adaptive() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 512);
assert_eq!(body["max_tokens"], 1024);
}
#[test]
fn test_rectify_budget_preserves_large_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 },
"max_tokens": 100000
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.max_tokens, Some(100000));
assert_eq!(result.after.max_tokens, Some(100000));
assert_eq!(body["max_tokens"], 100000);
}
#[test]
fn test_rectify_budget_creates_thinking_object_when_missing() {
let mut body = json!({
"model": "claude-test",
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type, None);
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(
result.after.thinking_budget_tokens,
Some(MAX_THINKING_BUDGET)
);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_no_max_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 512 }
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.max_tokens, None);
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_normalizes_non_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "disabled", "budget_tokens": 512 },
"max_tokens": 1024
});
let result = rectify_thinking_budget(&mut body);
assert!(result.applied);
assert_eq!(result.before.thinking_type.as_deref(), Some("disabled"));
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
assert_eq!(body["thinking"]["type"], "enabled");
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
}
#[test]
fn test_rectify_budget_no_change_when_already_valid() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 32000 },
"max_tokens": 64001
});
let result = rectify_thinking_budget(&mut body);
assert!(!result.applied);
assert_eq!(result.before, result.after);
assert_eq!(body["thinking"]["budget_tokens"], 32000);
assert_eq!(body["max_tokens"], 64001);
}
}
+271 -3
View File
@@ -59,10 +59,12 @@ pub fn should_rectify_thinking_signature(
}
// 场景3: expected thinking or redacted_thinking, found tool_use
// 与 CCH 对齐:要求明确包含 tool_use,避免过宽匹配。
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
if lower.contains("expected")
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("found")
&& lower.contains("tool_use")
{
return true;
}
@@ -73,6 +75,28 @@ pub fn should_rectify_thinking_signature(
return true;
}
// 场景5: signature 字段不被接受(第三方渠道)
// 错误示例: "xxx.signature: Extra inputs are not permitted"
if lower.contains("signature") && lower.contains("extra inputs are not permitted") {
return true;
}
// 场景6: thinking/redacted_thinking 块被修改
// 错误示例: "thinking or redacted_thinking blocks ... cannot be modified"
if (lower.contains("thinking") || lower.contains("redacted_thinking"))
&& lower.contains("cannot be modified")
{
return true;
}
// 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底)
if lower.contains("非法请求")
|| lower.contains("illegal request")
|| lower.contains("invalid request")
{
return true;
}
false
}
@@ -159,11 +183,13 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
/// 判断是否需要删除顶层 thinking 字段
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
// 检查 thinking 是否启用
let thinking_enabled = body
let thinking_type = body
.get("thinking")
.and_then(|t| t.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
.and_then(|t| t.as_str());
// 与 CCH 对齐:仅 type=enabled 视为开启
let thinking_enabled = thinking_type == Some("enabled");
if !thinking_enabled {
return false;
@@ -202,6 +228,11 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
}
/// 与 CCH 对齐:请求前不做 thinking type 主动改写。
pub fn normalize_thinking_type(body: Value) -> Value {
body
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,6 +242,7 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -218,6 +250,7 @@ mod tests {
RectifierConfig {
enabled: true,
request_thinking_signature: false,
request_thinking_budget: false,
}
}
@@ -225,6 +258,7 @@ mod tests {
RectifierConfig {
enabled: false,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
@@ -264,6 +298,14 @@ mod tests {
));
}
#[test]
fn test_no_detect_thinking_expected_without_tool_use() {
assert!(!should_rectify_thinking_signature(
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."),
&enabled_config()
));
}
#[test]
fn test_detect_must_start_with_thinking() {
assert!(should_rectify_thinking_signature(
@@ -418,4 +460,230 @@ mod tests {
// 此时会触发删除顶层 thinking 的逻辑
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
}
// ==================== 新增错误场景检测测试 ====================
#[test]
fn test_detect_signature_extra_inputs() {
// 场景5: signature 字段不被接受
assert!(should_rectify_thinking_signature(
Some("xxx.signature: Extra inputs are not permitted"),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_cannot_be_modified() {
// 场景6: thinking blocks cannot be modified
assert!(should_rectify_thinking_signature(
Some("thinking or redacted_thinking blocks in the response cannot be modified"),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_request() {
// 场景7: 非法请求(与 CCH 对齐,统一触发)
assert!(should_rectify_thinking_signature(
Some("非法请求:thinking signature 不合法"),
&enabled_config()
));
assert!(should_rectify_thinking_signature(
Some("illegal request: tool_use block mismatch"),
&enabled_config()
));
assert!(should_rectify_thinking_signature(
Some("invalid request: malformed JSON"),
&enabled_config()
));
}
#[test]
fn test_do_not_detect_thinking_type_tag_mismatch() {
// 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器
assert!(!should_rectify_thinking_signature(
Some("Input tag 'adaptive' found using 'type' does not match expected tags"),
&enabled_config()
));
}
// ==================== adaptive thinking type 测试 ====================
#[test]
fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert!(body["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_rectify_adaptive_preserves_existing_budget_tokens() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
assert_eq!(body["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_rectify_does_not_change_enabled_type() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 1024 },
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "hello" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "enabled");
}
#[test]
fn test_rectify_removes_top_level_thinking_adaptive() {
// 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
]
}, {
"role": "user",
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(!result.applied);
assert_eq!(body["thinking"]["type"], "adaptive");
}
#[test]
fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {
let mut body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" },
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "t", "signature": "sig_thinking" },
{ "type": "text", "text": "hello", "signature": "sig_text" }
]
}]
});
let result = rectify_anthropic_request(&mut body);
assert!(result.applied);
assert_eq!(result.removed_thinking_blocks, 1);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert!(content[0].get("signature").is_none());
assert_eq!(body["thinking"]["type"], "adaptive");
}
// ==================== normalize_thinking_type 测试 ====================
#[test]
fn test_normalize_thinking_type_adaptive_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert!(result["thinking"].get("budget_tokens").is_none());
}
#[test]
fn test_normalize_thinking_type_enabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "enabled", "budget_tokens": 2048 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 2048);
}
#[test]
fn test_normalize_thinking_type_disabled_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "disabled" }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "disabled");
}
#[test]
fn test_normalize_thinking_type_preserves_budget() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "adaptive", "budget_tokens": 5000 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "adaptive");
assert_eq!(result["thinking"]["budget_tokens"], 5000);
}
#[test]
fn test_normalize_thinking_type_no_thinking() {
let body = json!({
"model": "claude-test"
});
let result = normalize_thinking_type(body);
assert!(result.get("thinking").is_none());
}
#[test]
fn test_normalize_thinking_type_unknown_unchanged() {
let body = json!({
"model": "claude-test",
"thinking": { "type": "unexpected", "budget_tokens": 100 }
});
let result = normalize_thinking_type(body);
assert_eq!(result["thinking"]["type"], "unexpected");
assert_eq!(result["thinking"]["budget_tokens"], 100);
}
}
+46 -14
View File
@@ -195,17 +195,22 @@ pub struct AppProxyConfig {
/// 整流器配置
///
/// 存储在 settings 表中
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RectifierConfig {
/// 总开关:是否启用整流器
#[serde(default)]
/// 总开关:是否启用整流器(默认开启)
#[serde(default = "default_true")]
pub enabled: bool,
/// 请求整流:启用 thinking 签名整流器
/// 请求整流:启用 thinking 签名整流器(默认开启)
///
/// 处理错误:Invalid 'signature' in 'thinking' block
#[serde(default)]
#[serde(default = "default_true")]
pub request_thinking_signature: bool,
/// 请求整流:启用 thinking budget 整流器(默认开启)
///
/// 处理错误:budget_tokens + thinking 相关约束
#[serde(default = "default_true")]
pub request_thinking_budget: bool,
}
fn default_true() -> bool {
@@ -216,6 +221,16 @@ fn default_log_level() -> String {
"info".to_string()
}
impl Default for RectifierConfig {
fn default() -> Self {
Self {
enabled: true,
request_thinking_signature: true,
request_thinking_budget: true,
}
}
}
/// 日志配置
///
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
@@ -261,32 +276,49 @@ mod tests {
use super::*;
#[test]
fn test_rectifier_config_default_disabled() {
// 验证 RectifierConfig::default() 返回全禁用状态
fn test_rectifier_config_default_enabled() {
// 验证 RectifierConfig::default() 返回全开启状态
let config = RectifierConfig::default();
assert!(!config.enabled, "整流器总开关默认应为 false");
assert!(config.enabled, "整流器总开关默认应为 true");
assert!(
!config.request_thinking_signature,
"thinking 签名整流器默认应为 false"
config.request_thinking_signature,
"thinking 签名整流器默认应为 true"
);
assert!(
config.request_thinking_budget,
"thinking budget 整流器默认应为 true"
);
}
#[test]
fn test_rectifier_config_serde_default() {
// 验证反序列化缺字段时使用默认值 false
// 验证反序列化缺字段时使用默认值 true
let json = "{}";
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(!config.request_thinking_signature);
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
fn test_rectifier_config_serde_explicit_true() {
// 验证显式设置 true 时正确反序列化
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
let json =
r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
fn test_rectifier_config_serde_partial_fields() {
// 验证只设置部分字段时,缺失字段使用默认值 true
let json = r#"{"enabled": true, "requestThinkingSignature": false}"#;
let config: RectifierConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert!(!config.request_thinking_signature);
assert!(config.request_thinking_budget);
}
#[test]
+110 -15
View File
@@ -2,6 +2,7 @@ use crate::config::write_json_file;
use crate::database::OmoGlobalConfig;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::provider::{CommonConfigEnabledByApp, ProviderMeta};
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -142,6 +143,29 @@ impl OmoService {
}
}
fn resolve_common_config_enabled(provider: &crate::provider::Provider) -> bool {
// Unified path: use provider meta (same mechanism as other apps).
if let Some(meta) = provider.meta.as_ref() {
if let Some(enabled) = meta
.common_config_enabled_by_app
.as_ref()
.and_then(|by_app| by_app.opencode)
{
return enabled;
}
if let Some(enabled) = meta.common_config_enabled {
return enabled;
}
}
// Backward compatibility: legacy OMO providers stored this flag in settings_config.
provider
.settings_config
.get("useCommonConfig")
.and_then(|v| v.as_bool())
.unwrap_or(true)
}
pub fn delete_config_file() -> Result<(), AppError> {
let config_path = Self::config_path();
if config_path.exists() {
@@ -160,11 +184,7 @@ impl OmoService {
let agents = p.settings_config.get("agents").cloned();
let categories = p.settings_config.get("categories").cloned();
let other_fields = p.settings_config.get("otherFields").cloned();
let use_common_config = p
.settings_config
.get("useCommonConfig")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let use_common_config = Self::resolve_common_config_enabled(p);
(agents, categories, other_fields, use_common_config)
});
@@ -237,7 +257,6 @@ impl OmoService {
if let Some(categories) = obj.get("categories") {
settings.insert("categories".to_string(), categories.clone());
}
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields(&obj);
if !other.is_empty() {
@@ -263,7 +282,13 @@ impl OmoService {
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
notes: None,
meta: None,
meta: Some(ProviderMeta {
common_config_enabled_by_app: Some(CommonConfigEnabledByApp {
opencode: Some(true),
..Default::default()
}),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
@@ -286,10 +311,22 @@ impl OmoService {
let obj = Self::read_jsonc_object(&actual_path)?;
Ok(Self::build_local_file_data_from_obj(
&obj,
actual_path.to_string_lossy().to_string(),
last_modified,
))
}
fn build_local_file_data_from_obj(
obj: &Map<String, Value>,
file_path: String,
last_modified: Option<String>,
) -> OmoLocalFileData {
let agents = obj.get("agents").cloned();
let categories = obj.get("categories").cloned();
let other = Self::extract_other_fields(&obj);
let other = Self::extract_other_fields(obj);
let other_fields = if other.is_empty() {
None
} else {
@@ -297,16 +334,17 @@ impl OmoService {
};
let mut global = OmoGlobalConfig::default();
Self::merge_global_from_obj(&obj, &mut global);
Self::merge_global_from_obj(obj, &mut global);
global.other_fields = other_fields.clone();
Ok(OmoLocalFileData {
OmoLocalFileData {
agents,
categories,
other_fields,
global,
file_path: actual_path.to_string_lossy().to_string(),
file_path,
last_modified,
})
}
}
fn strip_jsonc_comments(input: &str) -> String {
@@ -400,7 +438,7 @@ mod tests {
..Default::default()
};
let agents = Some(serde_json::json!({
"Sisyphus": { "model": "claude-opus-4-5" }
"sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
@@ -411,7 +449,7 @@ mod tests {
assert_eq!(obj["$schema"], "https://example.com/schema.json");
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
assert!(obj.contains_key("agents"));
assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5");
assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5");
}
#[test]
@@ -422,7 +460,7 @@ mod tests {
..Default::default()
};
let agents = Some(serde_json::json!({
"Sisyphus": { "model": "claude-opus-4-5" }
"sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
@@ -434,4 +472,61 @@ mod tests {
assert!(!obj.contains_key("disabled_agents"));
assert!(obj.contains_key("agents"));
}
#[test]
fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() {
let obj = serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["oracle"],
"agents": {
"sisyphus": { "model": "claude-opus-4-6" }
},
"categories": {
"code": { "model": "gpt-5.3" }
},
"custom_top_level": {
"enabled": true
}
});
let obj_map = obj.as_object().unwrap().clone();
let data = OmoService::build_local_file_data_from_obj(
&obj_map,
"/tmp/oh-my-opencode.jsonc".to_string(),
None,
);
assert_eq!(
data.global.schema_url.as_deref(),
Some("https://example.com/schema.json")
);
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
assert_eq!(
data.other_fields,
Some(serde_json::json!({
"custom_top_level": { "enabled": true }
}))
);
assert_eq!(data.global.other_fields, data.other_fields);
}
#[test]
fn test_merge_config_ignores_non_object_other_fields() {
let global = OmoGlobalConfig {
other_fields: Some(serde_json::json!(["global_non_object"])),
..Default::default()
};
let agents = None;
let categories = None;
let other_fields = Some(serde_json::json!("profile_non_object"));
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("0"));
assert!(!obj.contains_key("global_non_object"));
assert!(!obj.contains_key("profile_non_object"));
}
}
+92 -13
View File
@@ -1,6 +1,14 @@
//! Live configuration operations
//!
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
//!
//! ## Common Config Runtime Merge
//!
//! When writing to live files, this module performs runtime merge of:
//! - `customConfig` (provider's settings_config) - provider-specific settings
//! - `commonConfig` (from database settings table) - shared template settings
//!
//! The merge follows the rule: customConfig overrides commonConfig.
use std::collections::HashMap;
@@ -9,6 +17,7 @@ use serde_json::{json, Value};
use crate::app_config::AppType;
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
use crate::config_merge::merge_config_for_live;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::mcp::McpService;
@@ -104,24 +113,86 @@ impl LiveSnapshot {
}
}
/// Write live configuration snapshot for a provider
/// Write live configuration snapshot for a provider (raw, without common config merge)
///
/// This function writes the provider's settings_config directly to the live file.
/// Use `write_live_snapshot_with_merge` for runtime merge with common config.
pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
write_live_snapshot_internal(app_type, provider, &provider.settings_config)
}
/// Write live configuration snapshot with common config runtime merge
///
/// This function performs runtime merge of:
/// - Provider's settings_config (custom config)
/// - Common config snippet from database (shared template)
///
/// The merge rule is: customConfig overrides commonConfig.
pub(crate) fn write_live_snapshot_with_merge(
state: &AppState,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
// Get common config snippet from database
let common_config_snippet = state.db.get_config_snippet(app_type.as_str())?;
// Use shared merge function (single source of truth)
let merge_result = merge_config_for_live(app_type, provider, common_config_snippet.as_deref());
// Log warning if any
if let Some(warning) = &merge_result.warning {
log::warn!(
"Common config merge warning for {:?} provider '{}': {}",
app_type,
provider.id,
warning
);
}
// Check if merge actually happened (config changed)
if merge_result.config != provider.settings_config {
log::debug!(
"Writing live config with common config merge for {:?} provider '{}'",
app_type,
provider.id
);
}
// Write the merged config to live file
let merged_provider = Provider {
settings_config: merge_result.config,
..provider.clone()
};
write_live_snapshot_internal(app_type, &merged_provider, &merged_provider.settings_config)
}
/// Internal function to write live configuration
fn write_live_snapshot_internal(
app_type: &AppType,
provider: &Provider,
config_to_write: &Value,
) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
let path = get_claude_settings_path();
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
let settings = sanitize_claude_settings_for_live(config_to_write);
write_json_file(&path, &settings)?;
}
AppType::Codex => {
let obj = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let obj = config_to_write.as_object().ok_or_else(|| {
AppError::Config(
"CODEX_CONFIG_NOT_OBJECT: settings_config must be a JSON object".to_string(),
)
})?;
let auth = obj.get("auth").ok_or_else(|| {
AppError::Config(
"CODEX_CONFIG_MISSING_AUTH: settings_config missing 'auth' field".to_string(),
)
})?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
AppError::Config(
"CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string(),
)
})?;
let auth_path = get_codex_auth_path();
@@ -131,7 +202,12 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
write_gemini_live(provider)?;
// Create a temporary provider with the merged config
let temp_provider = Provider {
settings_config: config_to_write.clone(),
..provider.clone()
};
write_gemini_live(&temp_provider)?;
}
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
@@ -139,7 +215,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
use crate::provider::OpenCodeProviderConfig;
// Defensive check: if settings_config is a full config structure, extract provider fragment
let config_to_write = if let Some(obj) = provider.settings_config.as_object() {
let config_to_write = if let Some(obj) = config_to_write.as_object() {
// Detect full config structure (has $schema or top-level provider field)
if obj.contains_key("$schema") || obj.contains_key("provider") {
log::warn!(
@@ -227,6 +303,8 @@ fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<()
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
///
/// This function uses `write_live_snapshot_with_merge` to perform runtime merge
/// with common config when enabled.
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Sync providers based on mode
@@ -244,7 +322,8 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
// Use write_live_snapshot_with_merge to support common config runtime merge
write_live_snapshot_with_merge(state, &app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
+133 -21
View File
@@ -13,6 +13,9 @@ use serde::Deserialize;
use serde_json::Value;
use crate::app_config::AppType;
use crate::config_merge::{
extract_json_difference, extract_toml_difference_str, is_common_config_enabled,
};
use crate::error::AppError;
use crate::provider::{Provider, UsageResult};
use crate::services::mcp::McpService;
@@ -27,7 +30,7 @@ pub use live::{
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::write_live_snapshot;
pub(crate) use live::{write_live_snapshot, write_live_snapshot_with_merge};
// Internal re-exports
use live::{remove_opencode_provider_from_live, write_gemini_live};
@@ -181,7 +184,8 @@ impl ProviderService {
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
write_live_snapshot(&app_type, &provider)?;
// Use write_live_snapshot_with_merge to support common config runtime merge
write_live_snapshot_with_merge(state, &app_type, &provider)?;
}
Ok(true)
@@ -241,7 +245,8 @@ impl ProviderService {
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
write_live_snapshot(&app_type, &provider)?;
// Use write_live_snapshot_with_merge to support common config runtime merge
write_live_snapshot_with_merge(state, &app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
@@ -454,18 +459,33 @@ impl ProviderService {
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
match (current_id, matches!(app_type, AppType::OpenCode)) {
(Some(current_id), false) if current_id != id => {
// Only backfill when switching to a different provider.
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
current_provider.settings_config = live_config;
// Ignore backfill failure, don't affect switch flow.
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
if let Some(current_id) = current_id {
if current_id != id {
// OpenCode uses additive mode - all providers coexist in the same file,
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !matches!(app_type, AppType::OpenCode) {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
// Check if common config is enabled for this provider
let common_enabled =
is_common_config_enabled(current_provider.meta.as_ref(), &app_type);
let config_to_save = if common_enabled {
// Extract custom config from live (remove common config parts)
Self::extract_custom_from_live(state, &app_type, &live_config)?
} else {
// Common config not enabled, use live config directly
live_config
};
current_provider.settings_config = config_to_save;
// Ignore backfill failure, don't affect switch flow
let _ = state.db.save_provider(app_type.as_str(), &current_provider);
}
}
}
}
_ => {}
}
// OpenCode uses additive mode - skip setting is_current (no such concept)
@@ -477,8 +497,9 @@ impl ProviderService {
state.db.set_current_provider(app_type.as_str(), id)?;
}
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_snapshot(&app_type, provider)?;
// Sync to live (use write_live_snapshot_with_merge for common config runtime merge)
// Note: write_gemini_live handles security flag internally for Gemini
write_live_snapshot_with_merge(state, &app_type, provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
@@ -531,6 +552,88 @@ impl ProviderService {
}
}
/// Extract custom config from live config (remove common config parts).
///
/// This is used during backfill to avoid polluting the provider's settings_config
/// with common config values that should remain in the common config snippet.
fn extract_custom_from_live(
state: &AppState,
app_type: &AppType,
live_config: &Value,
) -> Result<Value, AppError> {
// Get common config snippet from database
let common_snippet = state
.db
.get_config_snippet(app_type.as_str())?
.unwrap_or_default();
if common_snippet.trim().is_empty() {
// No common config, return live config as-is
return Ok(live_config.clone());
}
match app_type {
AppType::Claude => {
// Parse common config as JSON
let common_config: Value = serde_json::from_str(&common_snippet).map_err(|e| {
AppError::Config(format!("Failed to parse common config snippet: {e}"))
})?;
// Extract difference (custom = live - common)
let (custom_config, _) = extract_json_difference(live_config, &common_config);
Ok(custom_config)
}
AppType::Codex => {
// Codex: Extract TOML config field difference
let mut result = live_config.clone();
if let Some(config_str) = live_config.get("config").and_then(|v| v.as_str()) {
// Extract TOML difference for config field
// Returns (custom_toml, has_common_keys, error)
let (custom_toml, _, _) =
extract_toml_difference_str(config_str, &common_snippet);
if let Some(obj) = result.as_object_mut() {
obj.insert("config".to_string(), Value::String(custom_toml));
}
}
Ok(result)
}
AppType::Gemini => {
// Gemini: Extract env field difference
// Parse common config (supports ENV format and JSON format)
let common_env = crate::config_merge::parse_gemini_common_snippet(&common_snippet);
if common_env.is_empty() {
return Ok(live_config.clone());
}
let mut result = live_config.clone();
if let Some(live_env) = live_config.get("env").and_then(|v| v.as_object()) {
// Extract difference: custom = live_env - common_env
let mut custom_env = serde_json::Map::new();
for (key, value) in live_env {
if common_env.get(key) != Some(value) {
// Key doesn't exist in common or value is different
custom_env.insert(key.clone(), value.clone());
}
}
if let Some(obj) = result.as_object_mut() {
obj.insert("env".to_string(), Value::Object(custom_env));
}
}
Ok(result)
}
AppType::OpenCode => {
// OpenCode doesn't support common config
Ok(live_config.clone())
}
}
}
/// Extract common config for Claude (JSON format)
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
let mut config = settings.clone();
@@ -629,15 +732,19 @@ impl ProviderService {
Ok(cleaned.trim().to_string())
}
/// Extract common config for Gemini (JSON format)
/// Extract common config for Gemini (ENV format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
///
/// Returns ENV format (KEY=VALUE per line) instead of JSON.
/// Values containing newlines/carriage returns are skipped to prevent
/// ENV format injection/truncation.
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
let env = settings.get("env").and_then(|v| v.as_object());
let mut snippet = serde_json::Map::new();
let mut lines: Vec<String> = Vec::new();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
@@ -648,17 +755,22 @@ impl ProviderService {
};
let trimmed = v.trim();
if !trimmed.is_empty() {
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
// Skip values containing newlines to prevent ENV format injection
if trimmed.contains('\n') || trimmed.contains('\r') {
continue;
}
lines.push(format!("{key}={trimmed}"));
}
}
}
if snippet.is_empty() {
return Ok("{}".to_string());
if lines.is_empty() {
return Ok(String::new());
}
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
// Sort for consistent output
lines.sort();
Ok(lines.join("\n"))
}
/// Extract common config for OpenCode (JSON format)
+53 -8
View File
@@ -4,6 +4,7 @@
use crate::app_config::AppType;
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::config_merge::merge_config_for_live;
use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
@@ -1232,7 +1233,27 @@ impl ProxyService {
return Ok(false);
};
write_live_snapshot(app_type, provider)
// Use shared merge function (single source of truth)
let common_config_snippet = self.db.get_config_snippet(app_type.as_str()).ok().flatten();
let merge_result =
merge_config_for_live(app_type, provider, common_config_snippet.as_deref());
// Log warning if any
if let Some(warning) = &merge_result.warning {
log::warn!(
"Common config merge warning for {:?} provider '{}': {}",
app_type,
provider.id,
warning
);
}
// Write merged config to live file
let merged_provider = Provider {
settings_config: merge_result.config,
..provider.clone()
};
write_live_snapshot(app_type, &merged_provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
Ok(true)
@@ -1485,26 +1506,50 @@ impl ProxyService {
///
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
/// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。
///
/// **重要**: 新架构下 settings_config 存储的是自定义配置(custom diff),
/// 备份时需要先与 common config 合并,生成完整的 finalConfig。
pub async fn update_live_backup_from_provider(
&self,
app_type: &str,
provider: &Provider,
) -> Result<(), String> {
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
// Get common config snippet for merge
let common_snippet = self.db.get_config_snippet(app_type).ok().flatten();
// Merge custom config with common config to get final config
let merge_result =
merge_config_for_live(&app_type_enum, provider, common_snippet.as_deref());
// Log warning if any
if let Some(warning) = &merge_result.warning {
log::warn!(
"Common config merge warning for {} provider '{}': {}",
app_type,
provider.id,
warning
);
}
let final_config = merge_result.config;
let backup_json = match app_type {
"claude" => {
// Claude: settings_config 直接作为备份
serde_json::to_string(&provider.settings_config)
// Claude: 使用合并后的 final config 作为备份
serde_json::to_string(&final_config)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
}
"codex" => {
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
serde_json::to_string(&provider.settings_config)
// Codex: 使用合并后的 final config
serde_json::to_string(&final_config)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
}
"gemini" => {
// Gemini: 只提取 env 字段(与原始备份格式一致)
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
let env_backup = if let Some(env) = provider.settings_config.get("env") {
let env_backup = if let Some(env) = final_config.get("env") {
json!({ "env": env })
} else {
json!({ "env": {} })
@@ -1520,7 +1565,7 @@ impl ProxyService {
.await
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
log::info!("已更新 {app_type} Live 备份(热切换)");
log::info!("已更新 {app_type} Live 备份(热切换,含 common config 合并");
Ok(())
}
+104 -29
View File
@@ -174,6 +174,29 @@ impl SkillService {
Self
}
/// 构建 Skill 文档 URL(指向仓库中的 SKILL.md 文件)
fn build_skill_doc_url(owner: &str, repo: &str, branch: &str, doc_path: &str) -> String {
format!("https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}")
}
/// 从旧 readme_url 中提取仓库内文档路径,兼容 `blob`/`tree` 两种格式
fn extract_doc_path_from_url(url: &str) -> Option<String> {
let marker = if url.contains("/blob/") {
"/blob/"
} else if url.contains("/tree/") {
"/tree/"
} else {
return None;
};
let (_, tail) = url.split_once(marker)?;
let (_, path) = tail.split_once('/')?;
if path.is_empty() {
return None;
}
Some(path.to_string())
}
// ========== 路径管理 ==========
/// 获取 SSOT 目录(~/.cc-switch/skills/
@@ -298,6 +321,8 @@ impl SkillService {
let dest = ssot_dir.join(&install_name);
let mut repo_branch = skill.repo_branch.clone();
// 如果已存在则跳过下载
if !dest.exists() {
let repo = SkillRepo {
@@ -308,7 +333,7 @@ impl SkillService {
};
// 下载仓库
let temp_dir = timeout(
let (temp_dir, used_branch) = timeout(
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
@@ -324,6 +349,7 @@ impl SkillService {
Some("checkNetwork"),
))
})??;
repo_branch = used_branch;
// 复制到 SSOT
let source = temp_dir.join(&skill.directory);
@@ -338,8 +364,39 @@ impl SkillService {
Self::copy_dir_recursive(&source, &dest)?;
let _ = fs::remove_dir_all(&temp_dir);
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
if repo_branch != skill.repo_branch {
log::info!(
"Skill {}/{} 分支自动回退: {} -> {}",
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
repo_branch
);
}
}
let doc_path = skill
.readme_url
.as_deref()
.and_then(Self::extract_doc_path_from_url)
.map(|path| {
if path.ends_with("/SKILL.md") || path == "SKILL.md" {
path
} else {
format!("{}/SKILL.md", path.trim_end_matches('/'))
}
})
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
let readme_url = Some(Self::build_skill_doc_url(
&skill.repo_owner,
&skill.repo_name,
&repo_branch,
&doc_path,
));
// 创建 InstalledSkill 记录
let installed_skill = InstalledSkill {
id: skill.key.clone(),
@@ -352,8 +409,8 @@ impl SkillService {
directory: install_name.clone(),
repo_owner: Some(skill.repo_owner.clone()),
repo_name: Some(skill.repo_name.clone()),
repo_branch: Some(skill.repo_branch.clone()),
readme_url: skill.readme_url.clone(),
repo_branch: Some(repo_branch),
readme_url,
apps: SkillApps::only(current_app),
installed_at: chrono::Utc::now().timestamp(),
};
@@ -862,24 +919,26 @@ impl SkillService {
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let (temp_dir, resolved_branch) =
timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let mut skills = Vec::new();
let scan_dir = temp_dir.clone();
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
let mut resolved_repo = repo.clone();
resolved_repo.branch = resolved_branch;
self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?;
let _ = fs::remove_dir_all(&temp_dir);
@@ -907,7 +966,15 @@ impl SkillService {
.to_string()
};
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
let doc_path = skill_md
.strip_prefix(base_dir)
.unwrap_or(skill_md.as_path())
.to_string_lossy()
.replace('\\', "/");
if let Ok(skill) =
self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo)
{
skills.push(skill);
}
@@ -931,6 +998,7 @@ impl SkillService {
&self,
skill_md: &Path,
directory: &str,
doc_path: &str,
repo: &SkillRepo,
) -> Result<DiscoverableSkill> {
let meta = self.parse_skill_metadata(skill_md)?;
@@ -940,9 +1008,11 @@ impl SkillService {
name: meta.name.unwrap_or_else(|| directory.to_string()),
description: meta.description.unwrap_or_default(),
directory: directory.to_string(),
readme_url: Some(format!(
"https://github.com/{}/{}/tree/{}/{}",
repo.owner, repo.name, repo.branch, directory
readme_url: Some(Self::build_skill_doc_url(
&repo.owner,
&repo.name,
&repo.branch,
doc_path,
)),
repo_owner: repo.owner.clone(),
repo_name: repo.name.clone(),
@@ -994,16 +1064,21 @@ impl SkillService {
}
/// 下载仓库
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.path().to_path_buf();
let _ = temp_dir.keep();
let branches = if repo.branch.is_empty() {
vec!["main", "master"]
} else {
vec![repo.branch.as_str(), "main", "master"]
};
let mut branches = Vec::new();
if !repo.branch.is_empty() {
branches.push(repo.branch.as_str());
}
if !branches.contains(&"main") {
branches.push("main");
}
if !branches.contains(&"master") {
branches.push("master");
}
let mut last_error = None;
for branch in branches {
@@ -1014,7 +1089,7 @@ impl SkillService {
match self.download_and_extract(&url, &temp_path).await {
Ok(_) => {
return Ok(temp_path);
return Ok((temp_path, branch.to_string()));
}
Err(e) => {
last_error = Some(e);
+20 -2
View File
@@ -139,6 +139,17 @@ function App() {
}
}, [visibleApps, activeApp]);
// Fallback from sessions view when switching to an app without session support
useEffect(() => {
if (
currentView === "sessions" &&
activeApp !== "claude" &&
activeApp !== "codex"
) {
setCurrentView("providers");
}
}, [activeApp, currentView]);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmAction, setConfirmAction] = useState<{
@@ -177,6 +188,7 @@ function App() {
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const hasSkillsSupport = true;
const hasSessionSupport = activeApp === "claude" || activeApp === "codex";
const {
addProvider,
@@ -958,10 +970,16 @@ function App() {
variant="ghost"
size="sm"
onClick={() => setCurrentView("sessions")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSessionSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("sessionManager.title")}
>
<History className="w-4 h-4" />
<History className="flex-shrink-0 w-4 h-4" />
</Button>
<Button
variant="ghost"
-3
View File
@@ -53,15 +53,12 @@ export function DeepLinkImportDialog() {
const unlistenImport = listen<DeepLinkImportRequest>(
"deeplink-import",
async (event) => {
console.log("Deep link import event received:", event.payload);
// If config is present, merge it to get the complete configuration
if (event.payload.config || event.payload.configUrl) {
try {
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
event.payload,
);
console.log("Config merged successfully:", mergedRequest);
setRequest(mergedRequest);
} catch (error) {
console.error("Failed to merge config:", error);
+51 -5
View File
@@ -22,6 +22,10 @@ interface JsonEditorProps {
language?: "json" | "javascript";
height?: string | number;
showMinimap?: boolean; // 添加此属性以防未来使用
/** 只读模式 */
readOnly?: boolean;
/** 自动高度模式:根据内容自动调整高度,rows 作为最小行数 */
autoHeight?: boolean;
}
const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -33,6 +37,8 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
showValidation = true,
language = "json",
height,
readOnly = false,
autoHeight = false,
}) => {
const { t } = useTranslation();
const editorRef = useRef<HTMLDivElement>(null);
@@ -82,7 +88,14 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
if (!editorRef.current) return;
// 创建编辑器扩展
const minHeightPx = height ? undefined : Math.max(1, rows) * 18;
const lineHeight = 18;
const minHeightPx = height ? undefined : Math.max(1, rows) * lineHeight;
// 自动高度模式:计算内容行数
const contentLines = value ? value.split("\n").length : 1;
const autoHeightPx = autoHeight
? Math.max(rows, contentLines) * lineHeight + 10 // +10 for padding
: undefined;
// 使用 baseTheme 定义基础样式,优先级低于 oneDark,但可以正确响应主题
const baseTheme = EditorView.baseTheme({
@@ -123,9 +136,17 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
? `${height}px`
: height
: undefined;
// 确定最终高度:优先级 height > autoHeight > minHeight
const finalHeight = heightValue
? heightValue
: autoHeightPx
? `${autoHeightPx}px`
: undefined;
const sizingTheme = EditorView.theme({
"&": heightValue
? { height: heightValue }
"&": finalHeight
? { height: finalHeight }
: { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
@@ -150,6 +171,22 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
}),
];
// 如果是只读模式,添加只读扩展
if (readOnly) {
extensions.push(EditorState.readOnly.of(true));
extensions.push(
EditorView.theme({
".cm-editor": {
opacity: "0.8",
cursor: "default",
},
".cm-content": {
cursor: "default",
},
}),
);
}
// 如果启用深色模式,添加深色主题
if (darkMode) {
extensions.push(oneDark);
@@ -208,7 +245,16 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
view.destroy();
viewRef.current = null;
};
}, [darkMode, rows, height, language, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
}, [
darkMode,
rows,
height,
language,
jsonLinter,
readOnly,
autoHeight,
autoHeight ? value.split("\n").length : 0,
]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建;autoHeight 模式下根据行数变化重建
// 当 value 从外部改变时更新编辑器内容
useEffect(() => {
@@ -261,7 +307,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
className={isFullHeight ? "flex-1 min-h-0" : ""}
/>
{language === "json" && (
{language === "json" && !readOnly && (
<button
type="button"
onClick={handleFormat}
+113 -3
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
@@ -8,7 +9,13 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { providersApi, vscodeApi, type AppId } from "@/lib/api";
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
mapGeminiWarningToI18n,
} from "@/utils/providerConfigUtils";
interface EditProviderDialogProps {
open: boolean;
@@ -81,7 +88,103 @@ export function EditProviderDialog({
appId,
)) as Record<string, unknown>;
if (!cancelled && live && typeof live === "object") {
setLiveSettings(live);
// 检查是否启用了通用配置
const metaByApp = provider.meta?.commonConfigEnabledByApp;
const commonConfigEnabled =
metaByApp?.[appId] ??
provider.meta?.commonConfigEnabled ??
false;
if (commonConfigEnabled) {
// 从 live 配置中提取自定义部分(去除通用配置)
try {
const commonSnippet =
await configApi.getCommonConfigSnippet(appId);
if (commonSnippet && commonSnippet.trim()) {
if (appId === "codex") {
// Codex: 处理 TOML 格式的 config 字段
const liveConfig =
(live as { auth?: unknown; config?: string }).config ??
"";
const { customToml, error } = extractTomlDifference(
liveConfig,
commonSnippet.trim(),
);
if (!error) {
setLiveSettings({
...live,
config: customToml,
});
} else {
setLiveSettings(live);
}
} else if (appId === "gemini") {
// Gemini: common config supports three formats:
// - ENV format: KEY=VALUE lines
// - Flat JSON: {"KEY": "VALUE", ...}
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
const liveEnv =
(live as { env?: Record<string, string> }).env ?? {};
// Use shared parser with validation
const parseResult = parseGeminiCommonConfigSnippet(
commonSnippet,
{ strictForbiddenKeys: false },
);
if (parseResult.error) {
console.warn(
"[EditProviderDialog] Gemini common config parse error:",
parseResult.error,
);
setLiveSettings(live);
} else {
// Show warning toast if keys were filtered
if (parseResult.warning) {
toast.warning(
mapGeminiWarningToI18n(parseResult.warning, t),
);
}
if (
isPlainObject(liveEnv) &&
Object.keys(parseResult.env).length > 0
) {
const { customConfig } = extractDifference(
liveEnv,
parseResult.env,
);
setLiveSettings({
...live,
env: customConfig,
});
} else {
setLiveSettings(live);
}
}
} else {
// Claude: 处理 JSON 格式
const commonConfig = JSON.parse(commonSnippet.trim());
if (isPlainObject(live) && isPlainObject(commonConfig)) {
const { customConfig } = extractDifference(
live,
commonConfig,
);
setLiveSettings(customConfig);
} else {
setLiveSettings(live);
}
}
} else {
setLiveSettings(live);
}
} catch {
// 提取失败时使用原始 live 配置
setLiveSettings(live);
}
} else {
setLiveSettings(live);
}
setHasLoadedLive(true);
}
} catch {
@@ -105,7 +208,14 @@ export function EditProviderDialog({
return () => {
cancelled = true;
};
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
}, [
open,
provider?.id,
provider?.meta,
appId,
hasLoadedLive,
isProxyTakeover,
]); // 添加 provider?.meta 依赖
const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
@@ -30,6 +30,9 @@ interface CodexConfigEditorProps {
onExtract?: () => void;
isExtracting?: boolean;
/** 最终合并后的配置(只读预览) */
finalConfig?: string;
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
@@ -47,6 +50,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
configError,
onExtract,
isExtracting,
finalConfig,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -76,6 +80,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
configError={configError}
finalConfig={finalConfig}
/>
{/* Common Config Modal */}
@@ -1,6 +1,9 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { useDarkMode } from "@/hooks/useDarkMode";
interface CodexAuthSectionProps {
value: string;
@@ -19,22 +22,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
error,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const isDarkMode = useDarkMode();
const handleChange = (newValue: string) => {
onChange(newValue);
@@ -57,7 +45,8 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
onChange={handleChange}
placeholder={t("codexConfig.authJsonPlaceholder")}
darkMode={isDarkMode}
rows={6}
rows={3}
autoHeight={true}
showValidation={true}
language="json"
/>
@@ -83,6 +72,8 @@ interface CodexConfigSectionProps {
onEditCommonConfig: () => void;
commonConfigError?: string;
configError?: string;
/** 最终合并后的配置(只读预览) */
finalConfig?: string;
}
/**
@@ -96,24 +87,18 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
onEditCommonConfig,
commonConfigError,
configError,
finalConfig,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const isDarkMode = useDarkMode();
const [showPreview, setShowPreview] = useState(false);
// 当启用通用配置时,自动显示预览
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
if (useCommonConfig && finalConfig) {
setShowPreview(true);
}
}, [useCommonConfig, finalConfig]);
return (
<div className="space-y-2">
@@ -136,7 +121,32 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</label>
</div>
<div className="flex items-center justify-end">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{useCommonConfig && finalConfig && (
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{showPreview ? (
<>
<EyeOff className="w-3 h-3" />
{t("codexConfig.hidePreview", {
defaultValue: "隐藏合并预览",
})}
</>
) : (
<>
<Eye className="w-3 h-3" />
{t("codexConfig.showPreview", {
defaultValue: "显示合并预览",
})}
</>
)}
</button>
)}
</div>
<button
type="button"
onClick={onEditCommonConfig}
@@ -152,15 +162,58 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</p>
)}
<JsonEditor
value={value}
onChange={onChange}
placeholder=""
darkMode={isDarkMode}
rows={8}
showValidation={false}
language="javascript"
/>
{/* 自定义配置编辑器 */}
<div className="space-y-1">
{useCommonConfig && showPreview && (
<Label className="text-xs text-muted-foreground">
{t("codexConfig.customConfig", {
defaultValue: "自定义配置(覆盖通用配置)",
})}
</Label>
)}
<JsonEditor
value={value}
onChange={onChange}
placeholder=""
darkMode={isDarkMode}
rows={useCommonConfig && showPreview ? 3 : 8}
autoHeight={useCommonConfig && showPreview}
showValidation={false}
language="javascript"
/>
</div>
{/* 合并预览(只读)- 放在自定义配置下面 */}
{useCommonConfig && showPreview && finalConfig && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t("codexConfig.mergedPreview", {
defaultValue: "合并预览(只读)",
})}
</Label>
<span className="text-xs text-green-500 dark:text-green-400">
{t("codexConfig.mergedPreviewHint", {
defaultValue: "通用配置 + 自定义配置 = 最终配置",
})}
</span>
</div>
<div className="relative">
<JsonEditor
value={finalConfig}
onChange={() => {}} // 只读
darkMode={isDarkMode}
rows={6}
showValidation={false}
language="javascript"
readOnly={true}
/>
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
{t("common.readonly", { defaultValue: "只读" })}
</div>
</div>
</div>
)}
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
@@ -3,8 +3,9 @@ import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, Download, Loader2 } from "lucide-react";
import { Save, Download, Loader2, Eye, EyeOff } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import { useDarkMode } from "@/hooks/useDarkMode";
interface CommonConfigEditorProps {
value: string;
@@ -19,6 +20,8 @@ interface CommonConfigEditorProps {
onModalClose: () => void;
onExtract?: () => void;
isExtracting?: boolean;
/** 最终合并后的配置(只读预览) */
finalConfig?: string;
}
export function CommonConfigEditor({
@@ -34,24 +37,18 @@ export function CommonConfigEditor({
onModalClose,
onExtract,
isExtracting,
finalConfig,
}: CommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const isDarkMode = useDarkMode();
const [showPreview, setShowPreview] = useState(false);
// 当启用通用配置时,自动显示预览
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
if (useCommonConfig && finalConfig) {
setShowPreview(true);
}
}, [useCommonConfig, finalConfig]);
return (
<>
@@ -75,7 +72,32 @@ export function CommonConfigEditor({
</label>
</div>
</div>
<div className="flex items-center justify-end">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{useCommonConfig && finalConfig && (
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{showPreview ? (
<>
<EyeOff className="w-3 h-3" />
{t("claudeConfig.hidePreview", {
defaultValue: "隐藏合并预览",
})}
</>
) : (
<>
<Eye className="w-3 h-3" />
{t("claudeConfig.showPreview", {
defaultValue: "显示合并预览",
})}
</>
)}
</button>
)}
</div>
<button
type="button"
onClick={onEditClick}
@@ -91,20 +113,64 @@ export function CommonConfigEditor({
{commonConfigError}
</p>
)}
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
{/* 自定义配置编辑器 */}
<div className="space-y-1">
{useCommonConfig && showPreview && (
<Label className="text-xs text-muted-foreground">
{t("claudeConfig.customConfig", {
defaultValue: "自定义配置(覆盖通用配置)",
})}
</Label>
)}
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
darkMode={isDarkMode}
rows={14}
showValidation={true}
language="json"
/>
darkMode={isDarkMode}
rows={useCommonConfig && showPreview ? 3 : 14}
autoHeight={useCommonConfig && showPreview}
showValidation={true}
language="json"
/>
</div>
{/* 合并预览(只读)- 放在自定义配置下面 */}
{useCommonConfig && showPreview && finalConfig && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t("claudeConfig.mergedPreview", {
defaultValue: "合并预览(只读)",
})}
</Label>
<span className="text-xs text-green-500 dark:text-green-400">
{t("claudeConfig.mergedPreviewHint", {
defaultValue: "通用配置 + 自定义配置 = 最终配置",
})}
</span>
</div>
<div className="relative">
<JsonEditor
value={finalConfig}
onChange={() => {}} // 只读
darkMode={isDarkMode}
rows={8}
showValidation={false}
language="json"
readOnly={true}
/>
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
{t("common.readonly", { defaultValue: "只读" })}
</div>
</div>
</div>
)}
</div>
<FullScreenPanel
@@ -18,6 +18,7 @@ interface GeminiCommonConfigModalProps {
/**
* GeminiCommonConfigModal - Common Gemini configuration editor modal
* Allows editing of common env snippet shared across Gemini providers
* Uses ENV format (KEY=VALUE) instead of JSON
*/
export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
@@ -88,13 +89,14 @@ export const GeminiCommonConfigModal: React.FC<
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"GEMINI_MODEL": "gemini-3-pro-preview"
}`}
placeholder={`# Gemini 通用配置
# 格式: KEY=VALUE
GEMINI_MODEL=gemini-2.5-pro`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
showValidation={false}
language="javascript"
/>
{error && (
@@ -17,6 +17,8 @@ interface GeminiConfigEditorProps {
configError: string;
onExtract?: () => void;
isExtracting?: boolean;
/** 最终合并后的 env 配置(只读预览) */
finalEnv?: string;
}
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
@@ -34,6 +36,7 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
configError,
onExtract,
isExtracting,
finalEnv,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
@@ -56,6 +59,7 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onCommonConfigToggle={onCommonConfigToggle}
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
finalEnv={finalEnv}
/>
{/* Config JSON Section */}
@@ -1,6 +1,9 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { useDarkMode } from "@/hooks/useDarkMode";
interface GeminiEnvSectionProps {
value: string;
@@ -11,6 +14,8 @@ interface GeminiEnvSectionProps {
onCommonConfigToggle: (checked: boolean) => void;
onEditCommonConfig: () => void;
commonConfigError?: string;
/** 最终合并后的 env 配置(只读预览) */
finalEnv?: string;
}
/**
@@ -25,24 +30,18 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
onCommonConfigToggle,
onEditCommonConfig,
commonConfigError,
finalEnv,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const isDarkMode = useDarkMode();
const [showPreview, setShowPreview] = useState(false);
// 当启用通用配置时,自动显示预览
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
if (useCommonConfig && finalEnv) {
setShowPreview(true);
}
}, [useCommonConfig, finalEnv]);
const handleChange = (newValue: string) => {
onChange(newValue);
@@ -74,7 +73,32 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
</label>
</div>
<div className="flex items-center justify-end">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{useCommonConfig && finalEnv && (
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="inline-flex items-center gap-1 text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{showPreview ? (
<>
<EyeOff className="w-3 h-3" />
{t("geminiConfig.hidePreview", {
defaultValue: "隐藏合并预览",
})}
</>
) : (
<>
<Eye className="w-3 h-3" />
{t("geminiConfig.showPreview", {
defaultValue: "显示合并预览",
})}
</>
)}
</button>
)}
</div>
<button
type="button"
onClick={onEditCommonConfig}
@@ -92,17 +116,60 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
</p>
)}
<JsonEditor
value={value}
onChange={handleChange}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
{/* 自定义配置编辑器 */}
<div className="space-y-1">
{useCommonConfig && showPreview && (
<Label className="text-xs text-muted-foreground">
{t("geminiConfig.customConfig", {
defaultValue: "自定义配置(覆盖通用配置)",
})}
</Label>
)}
<JsonEditor
value={value}
onChange={handleChange}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
GEMINI_API_KEY=sk-your-api-key-here
GEMINI_MODEL=gemini-3-pro-preview`}
darkMode={isDarkMode}
rows={6}
showValidation={false}
language="javascript"
/>
darkMode={isDarkMode}
rows={useCommonConfig && showPreview ? 3 : 6}
autoHeight={useCommonConfig && showPreview}
showValidation={false}
language="javascript"
/>
</div>
{/* 合并预览(只读)- 放在自定义配置下面 */}
{useCommonConfig && showPreview && finalEnv && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t("geminiConfig.mergedPreview", {
defaultValue: "合并预览(只读)",
})}
</Label>
<span className="text-xs text-green-500 dark:text-green-400">
{t("geminiConfig.mergedPreviewHint", {
defaultValue: "通用配置 + 自定义配置 = 最终配置",
})}
</span>
</div>
<div className="relative">
<JsonEditor
value={finalEnv}
onChange={() => {}} // 只读
darkMode={isDarkMode}
rows={4}
showValidation={false}
language="javascript"
readOnly={true}
/>
<div className="absolute top-2 right-2 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 text-xs rounded">
{t("common.readonly", { defaultValue: "只读" })}
</div>
</div>
</div>
)}
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
@@ -134,22 +201,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
configError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const isDarkMode = useDarkMode();
return (
<div className="space-y-2">
+267 -89
View File
@@ -12,6 +12,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Plus,
Trash2,
@@ -21,6 +34,10 @@ import {
Settings,
FolderInput,
Loader2,
HelpCircle,
Check,
ChevronsUpDown,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@@ -43,6 +60,13 @@ const ADVANCED_PLACEHOLDER = `{
interface OmoFormFieldsProps {
modelOptions: Array<{ value: string; label: string }>;
modelVariantsMap?: Record<string, string[]>;
presetMetaMap?: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>;
agents: Record<string, Record<string, unknown>>;
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
categories: Record<string, Record<string, unknown>>;
@@ -53,19 +77,149 @@ interface OmoFormFieldsProps {
onOtherFieldsStrChange: (value: string) => void;
}
type CustomModelItem = { key: string; model: string };
export type CustomModelItem = {
key: string;
model: string;
sourceKey?: string;
};
type BuiltinModelDef = Pick<
OmoAgentDef | OmoCategoryDef,
"key" | "display" | "descZh" | "descEn" | "recommended"
"key" | "display" | "descKey" | "recommended" | "tooltipKey"
>;
type ModelOption = { value: string; label: string };
function DeferredKeyInput({
value,
onCommit,
placeholder,
className,
}: {
value: string;
onCommit: (value: string) => void;
placeholder?: string;
className?: string;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
return (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
if (draft !== value) {
onCommit(draft);
}
}}
placeholder={placeholder}
className={className}
/>
);
}
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__";
const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__";
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__";
function ModelCombobox({
value,
options,
recommended,
onChange,
}: {
value: string;
options: ModelOption[];
recommended?: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => o.value === value)?.label;
const selectModelText = t("omo.selectModel", {
defaultValue: "Select configured model",
});
const placeholderText = recommended
? `${selectModelText} (${t("omo.recommendedHint", { model: recommended, defaultValue: "Recommended: {{model}}" })})`
: selectModelText;
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
className="flex flex-1 h-8 items-center justify-between whitespace-nowrap rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm ring-offset-background focus:outline-none focus-visible:outline-none focus:border-border-default focus-visible:border-border-default focus:ring-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={cn("truncate", !value && "text-muted-foreground")}>
{selectedLabel || placeholderText}
</span>
<span className="flex items-center shrink-0 ml-1 gap-0.5">
{value && (
<X
className="h-3.5 w-3.5 opacity-50 hover:opacity-100 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
/>
)}
<ChevronsUpDown className="h-3.5 w-3.5 opacity-50" />
</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
sideOffset={6}
avoidCollisions={true}
collisionPadding={8}
className="z-[1000] w-[var(--radix-popover-trigger-width)] p-0 border-border-default"
>
<Command>
<CommandInput
placeholder={t("omo.searchModel", {
defaultValue: "Search model...",
})}
/>
<CommandList>
<CommandEmpty>
{t("omo.noEnabledModels", {
defaultValue: "No configured models",
})}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label]}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function getAdvancedStr(config: Record<string, unknown> | undefined): string {
if (!config) return "";
@@ -86,24 +240,58 @@ function collectCustomModels(
customs.push({
key: k,
model: ((v as Record<string, unknown>).model as string) || "",
sourceKey: k,
});
}
}
return customs;
}
function mergeCustomModelsIntoStore(
export function mergeCustomModelsIntoStore(
store: Record<string, Record<string, unknown>>,
builtinKeys: Set<string>,
customs: CustomModelItem[],
modelVariantsMap: Record<string, string[]>,
): Record<string, Record<string, unknown>> {
const updated = { ...store };
for (const key of Object.keys(updated)) {
if (!builtinKeys.has(key)) delete updated[key];
const updated: Record<string, Record<string, unknown>> = {};
for (const [key, value] of Object.entries(store)) {
if (builtinKeys.has(key)) {
updated[key] = { ...value };
}
}
for (const custom of customs) {
if (custom.key.trim()) {
updated[custom.key] = { ...updated[custom.key], model: custom.model };
const targetKey = custom.key.trim();
if (!targetKey) continue;
const sourceKey = (custom.sourceKey || targetKey).trim();
const sourceEntry = store[sourceKey] ?? store[targetKey];
const nextEntry = {
...(updated[targetKey] || {}),
...(sourceEntry || {}),
};
if (custom.model.trim()) {
nextEntry.model = custom.model;
const currentVariant =
typeof nextEntry.variant === "string" ? nextEntry.variant : "";
if (currentVariant) {
const validVariants = modelVariantsMap[custom.model] || [];
if (!validVariants.includes(currentVariant)) {
delete nextEntry.variant;
}
}
updated[targetKey] = nextEntry;
continue;
}
delete nextEntry.model;
delete nextEntry.variant;
if (Object.keys(nextEntry).length > 0) {
updated[targetKey] = nextEntry;
} else {
delete updated[targetKey];
}
}
return updated;
@@ -112,6 +300,7 @@ function mergeCustomModelsIntoStore(
export function OmoFormFields({
modelOptions,
modelVariantsMap = {},
presetMetaMap: _presetMetaMap = {},
agents,
onAgentsChange,
categories,
@@ -119,8 +308,7 @@ export function OmoFormFields({
otherFieldsStr,
onOtherFieldsStrChange,
}: OmoFormFieldsProps) {
const { t, i18n } = useTranslation();
const isZh = i18n.language?.startsWith("zh");
const { t } = useTranslation();
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
@@ -159,19 +347,29 @@ export function OmoFormFields({
const syncCustomAgents = useCallback(
(customs: CustomModelItem[]) => {
onAgentsChange(
mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs),
mergeCustomModelsIntoStore(
agents,
BUILTIN_AGENT_KEYS,
customs,
modelVariantsMap,
),
);
},
[agents, onAgentsChange],
[agents, onAgentsChange, modelVariantsMap],
);
const syncCustomCategories = useCallback(
(customs: CustomModelItem[]) => {
onCategoriesChange(
mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs),
mergeCustomModelsIntoStore(
categories,
BUILTIN_CATEGORY_KEYS,
customs,
modelVariantsMap,
),
);
},
[categories, onCategoriesChange],
[categories, onCategoriesChange, modelVariantsMap],
);
const buildEffectiveModelOptions = useCallback(
@@ -212,43 +410,16 @@ export function OmoFormFields({
const renderModelSelect = (
currentModel: string,
onChange: (value: string) => void,
placeholder?: string,
recommended?: string,
) => {
const options = buildEffectiveModelOptions(currentModel);
return (
<Select
value={currentModel || EMPTY_MODEL_VALUE}
onValueChange={(value) =>
onChange(value === EMPTY_MODEL_VALUE ? "" : value)
}
>
<SelectTrigger className="flex-1 h-8 text-sm">
<SelectValue
placeholder={
placeholder ||
t("omo.selectEnabledModel", {
defaultValue: "Select enabled model",
})
}
/>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_MODEL_VALUE}>
{t("omo.clearWrapped", { defaultValue: "(Clear)" })}
</SelectItem>
{options.length === 0 ? (
<SelectItem value={UNAVAILABLE_MODEL_VALUE} disabled>
{t("omo.noEnabledModels", { defaultValue: "No enabled models" })}
</SelectItem>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
<ModelCombobox
value={currentModel}
options={options}
recommended={recommended}
onChange={onChange}
/>
);
};
@@ -268,11 +439,21 @@ export function OmoFormFields({
currentVariant: string,
onChange: (value: string) => void,
) => {
const hasModel = Boolean(currentModel);
const modelVariantKeys = hasModel
? modelVariantsMap[currentModel] || []
: [];
const hasVariants = modelVariantKeys.length > 0;
const shouldShow = hasModel && (hasVariants || Boolean(currentVariant));
if (!shouldShow) {
return null;
}
const variantOptions = buildEffectiveVariantOptions(
currentModel,
currentVariant,
);
const hasModel = Boolean(currentModel);
const firstIsUnavailable =
Boolean(currentVariant) &&
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
@@ -283,9 +464,8 @@ export function OmoFormFields({
onValueChange={(value) =>
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
}
disabled={!hasModel}
>
<SelectTrigger className="w-32 h-8 text-xs shrink-0">
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
<SelectValue
placeholder={t("omo.variantPlaceholder", {
defaultValue: "variant",
@@ -296,30 +476,16 @@ export function OmoFormFields({
<SelectItem value={EMPTY_VARIANT_VALUE}>
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
</SelectItem>
{!hasModel ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.selectModelFirst", {
defaultValue: "Select model first",
})}
{variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
</SelectItem>
) : variantOptions.length === 0 ? (
<SelectItem value={UNAVAILABLE_VARIANT_VALUE} disabled>
{t("omo.noVariantsForModel", {
defaultValue: "No variants for model",
})}
</SelectItem>
) : (
variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
</SelectItem>
))
)}
))}
</SelectContent>
</Select>
);
@@ -536,7 +702,7 @@ export function OmoFormFields({
toast.warning(
t("omo.noEnabledModelsWarning", {
defaultValue:
"No enabled models available. Configure and enable OpenCode models first.",
"No configured models available. Configure OpenCode models first.",
}),
);
return;
@@ -641,9 +807,17 @@ export function OmoFormFields({
<div key={key} className="border-b border-border/30 last:border-b-0">
<div className="flex items-center gap-2 py-1.5">
<div className="w-32 shrink-0">
<div className="text-sm font-medium">{def.display}</div>
<div className="flex items-center gap-1 text-sm font-medium">
{def.display}
<span className="relative inline-flex group/tip">
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground cursor-help shrink-0" />
<span className="invisible opacity-0 group-hover/tip:visible group-hover/tip:opacity-100 transition-opacity duration-150 absolute left-0 top-full mt-1 z-50 w-[260px] rounded-md bg-popover text-popover-foreground border border-border shadow-md px-3 py-2 text-xs leading-relaxed font-normal pointer-events-none">
{t(def.tooltipKey)}
</span>
</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{isZh ? def.descZh : def.descEn}
{t(def.descKey)}
</div>
</div>
{renderModelSelect(
@@ -727,16 +901,14 @@ export function OmoFormFields({
className="border-b border-border/30 last:border-b-0"
>
<div className="flex items-center gap-2 py-1.5">
<Input
<DeferredKeyInput
value={item.key}
onChange={(e) => updateCustom({ key: e.target.value })}
onCommit={(value) => updateCustom({ key: value })}
placeholder={keyPlaceholder}
className="w-32 shrink-0 h-8 text-sm text-primary"
/>
{renderModelSelect(
item.model,
(value) => updateCustom({ model: value }),
t("omo.modelNamePlaceholder", { defaultValue: "model-name" }),
{renderModelSelect(item.model, (value) =>
updateCustom({ model: value }),
)}
{renderVariantSelect(item.model, currentVariant, (value) => {
if (!item.key) return;
@@ -877,11 +1049,17 @@ export function OmoFormFields({
const addCustomModel = (scope: AdvancedScope) => {
if (scope === "agent") {
setCustomAgents((prev) => [...prev, { key: "", model: "" }]);
setCustomAgents((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setSubAgentsOpen(true);
return;
}
setCustomCategories((prev) => [...prev, { key: "", model: "" }]);
setCustomCategories((prev) => [
...prev,
{ key: "", model: "", sourceKey: "" },
]);
setCategoriesOpen(true);
};
@@ -931,7 +1109,7 @@ export function OmoFormFields({
·{" "}
{t("omo.enabledModelsCount", {
count: modelOptions.length,
defaultValue: "{{count}} enabled models available",
defaultValue: "{{count}} configured models available",
})}
</span>
{localFilePath && (
+523 -126
View File
@@ -7,12 +7,13 @@ import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import { providersApi, type AppId } from "@/lib/api";
import { providersApi, configApi, type AppId } from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
ProviderTestConfig,
ProviderProxyConfig,
CommonConfigEnabledByApp,
ClaudeApiFormat,
OpenCodeModel,
OpenCodeProviderConfig,
@@ -31,12 +32,19 @@ import {
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
OPENCODE_PRESET_MODEL_VARIANTS,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
mapGeminiWarningToI18n,
} from "@/utils/providerConfigUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
@@ -51,9 +59,8 @@ import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields";
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
import * as configApi from "@/lib/api/config";
import type { OmoGlobalConfig } from "@/types/omo";
import { mergeOmoConfigPreview } from "@/types/omo";
import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
@@ -66,16 +73,49 @@ import {
useCodexConfigState,
useApiKeyLink,
useTemplateValues,
useCommonConfigSnippet,
useCodexCommonConfig,
useSpeedTestEndpoints,
useCodexTomlValidation,
useGeminiConfigState,
useGeminiCommonConfig,
} from "./hooks";
import { useCommonConfigBase } from "@/hooks/useCommonConfigBase";
import {
claudeAdapter,
createGeminiAdapter,
} from "@/hooks/commonConfigAdapters";
import { useProvidersQuery } from "@/lib/query/queries";
import { useOmoGlobalConfig } from "@/lib/query/omo";
/**
* Parse Gemini common config snippet for difference extraction.
* Uses shared parser with non-strict forbidden keys (filter instead of reject).
*
* Supports three formats:
* - ENV format: KEY=VALUE lines (one per line)
* - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
*
* Returns { env, warning } - caller should display warning via toast if present.
*/
function parseGeminiCommonConfig(snippet: string): {
env: Record<string, string>;
warning?: string;
} {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: false, // Don't fail, just filter
});
if (result.error) {
console.warn(
"[ProviderForm] Gemini common config parse error:",
result.error,
);
return { env: {} };
}
return { env: result.env, warning: result.warning };
}
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
const GEMINI_DEFAULT_CONFIG = JSON.stringify(
@@ -112,21 +152,25 @@ const isKnownOpencodeOptionKey = (key: string) =>
function parseOpencodeConfig(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const normalize = (
parsed: Partial<OpenCodeProviderConfig>,
): OpenCodeProviderConfig => ({
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
});
try {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
return normalize(parsed);
} catch {
return {
npm: OPENCODE_DEFAULT_NPM,
@@ -136,6 +180,25 @@ function parseOpencodeConfig(
}
}
function parseOpencodeConfigStrict(
settingsConfig?: Record<string, unknown>,
): OpenCodeProviderConfig {
const parsed = JSON.parse(
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
) as Partial<OpenCodeProviderConfig>;
return {
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
options:
parsed.options && typeof parsed.options === "object"
? (parsed.options as OpenCodeProviderConfig["options"])
: {},
models:
parsed.models && typeof parsed.models === "object"
? (parsed.models as Record<string, OpenCodeModel>)
: {},
};
}
function toOpencodeExtraOptions(
options: OpenCodeProviderConfig["options"],
): Record<string, string> {
@@ -195,8 +258,10 @@ function buildOmoProfilePreview(
}
if (otherFieldsStr.trim()) {
try {
const other = JSON.parse(otherFieldsStr);
Object.assign(profileOnly, other);
const other = parseOmoOtherFieldsObject(otherFieldsStr);
if (other) {
Object.assign(profileOnly, other);
}
} catch {}
}
return profileOnly;
@@ -381,8 +446,12 @@ export function ProviderForm({
resetCodexConfig,
} = useCodexConfigState({ initialData });
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
// 使用 Codex TOML 校验 hook (仅 Codex 模式)
const {
configError: codexConfigError,
debouncedValidate,
validateToml,
} = useCodexTomlValidation();
const handleCodexConfigChange = useCallback(
(value: string) => {
@@ -458,6 +527,18 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
// 使用通用配置片段 hook (仅 Claude 模式)
// 直接使用 useCommonConfigBase + claudeAdapter
const claudeCommonConfig = useCommonConfigBase({
adapter: claudeAdapter,
inputValue: form.getValues("settingsConfig"),
onInputChange: (config) => form.setValue("settingsConfig", config),
initialData: appId === "claude" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
// 解构 Claude 通用配置
const {
useCommonConfig,
commonConfigSnippet,
@@ -466,13 +547,11 @@ export function ProviderForm({
handleCommonConfigSnippetChange,
isExtracting: isClaudeExtracting,
handleExtract: handleClaudeExtract,
} = useCommonConfigSnippet({
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: (config) => form.setValue("settingsConfig", config),
initialData: appId === "claude" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
isLoading: isClaudeCommonConfigLoading,
finalValue: claudeFinalConfig,
getPendingCommonConfigSnippet: getPendingClaudeCommonConfigSnippet,
markCommonConfigSaved: markClaudeCommonConfigSaved,
} = claudeCommonConfig;
const {
useCommonConfig: useCodexCommonConfigFlag,
@@ -482,11 +561,16 @@ export function ProviderForm({
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
isExtracting: isCodexExtracting,
handleExtract: handleCodexExtract,
isLoading: isCodexCommonConfigLoading,
finalConfig: codexFinalConfig,
getPendingCommonConfigSnippet: getPendingCodexCommonConfigSnippet,
markCommonConfigSaved: markCodexCommonConfigSaved,
} = useCodexCommonConfig({
codexConfig,
onConfigChange: handleCodexConfigChange,
initialData: appId === "codex" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
currentProviderId: providerId,
});
const {
@@ -509,52 +593,76 @@ export function ProviderForm({
initialData: appId === "gemini" ? initialData : undefined,
});
const updateGeminiEnvField = useCallback(
(
key: "GEMINI_API_KEY" | "GOOGLE_GEMINI_BASE_URL" | "GEMINI_MODEL",
value: string,
) => {
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
env?: Record<string, unknown>;
};
if (!config.env || typeof config.env !== "object") {
config.env = {};
}
config.env[key] = value;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {}
},
[form],
);
const handleGeminiApiKeyChange = useCallback(
(key: string) => {
originalHandleGeminiApiKeyChange(key);
updateGeminiEnvField("GEMINI_API_KEY", key.trim());
// 同步更新 settingsConfig
let config: { env?: Record<string, unknown> };
try {
config = JSON.parse(form.getValues("settingsConfig") || "{}");
} catch {
// 解析失败时初始化为有效结构
config = { env: {} };
}
if (!config.env) config.env = {};
config.env.GEMINI_API_KEY = key.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
},
[originalHandleGeminiApiKeyChange, updateGeminiEnvField],
[originalHandleGeminiApiKeyChange, form],
);
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
originalHandleGeminiBaseUrlChange(url);
updateGeminiEnvField(
"GOOGLE_GEMINI_BASE_URL",
url.trim().replace(/\/+$/, ""),
);
// 同步更新 settingsConfig
let config: { env?: Record<string, unknown> };
try {
config = JSON.parse(form.getValues("settingsConfig") || "{}");
} catch {
// 解析失败时初始化为有效结构
config = { env: {} };
}
if (!config.env) config.env = {};
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
},
[originalHandleGeminiBaseUrlChange, updateGeminiEnvField],
[originalHandleGeminiBaseUrlChange, form],
);
const handleGeminiModelChange = useCallback(
(model: string) => {
originalHandleGeminiModelChange(model);
updateGeminiEnvField("GEMINI_MODEL", model.trim());
// 同步更新 settingsConfig
let config: { env?: Record<string, unknown> };
try {
config = JSON.parse(form.getValues("settingsConfig") || "{}");
} catch {
// 解析失败时初始化为有效结构
config = { env: {} };
}
if (!config.env) config.env = {};
config.env.GEMINI_MODEL = model.trim();
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
},
[originalHandleGeminiModelChange, updateGeminiEnvField],
[originalHandleGeminiModelChange, form],
);
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
// 直接使用 useCommonConfigBase + createGeminiAdapter
const geminiAdapter = useMemo(
() => createGeminiAdapter({ envStringToObj, envObjToString }),
[envStringToObj, envObjToString],
);
const geminiCommonConfig = useCommonConfigBase({
adapter: geminiAdapter,
inputValue: geminiEnv,
onInputChange: handleGeminiEnvChange,
initialData: appId === "gemini" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "gemini",
});
// 解构 Gemini 通用配置
const {
useCommonConfig: useGeminiCommonConfigFlag,
commonConfigSnippet: geminiCommonConfigSnippet,
@@ -563,14 +671,11 @@ export function ProviderForm({
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
isExtracting: isGeminiExtracting,
handleExtract: handleGeminiExtract,
} = useGeminiCommonConfig({
envValue: geminiEnv,
onEnvChange: handleGeminiEnvChange,
envStringToObj,
envObjToString,
initialData: appId === "gemini" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
isLoading: isGeminiCommonConfigLoading,
finalValue: geminiFinalEnv,
getPendingCommonConfigSnippet: getPendingGeminiCommonConfigSnippet,
markCommonConfigSaved: markGeminiCommonConfigSaved,
} = geminiCommonConfig;
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
const existingOpencodeKeys = useMemo(() => {
@@ -580,19 +685,24 @@ export function ProviderForm({
);
}, [opencodeProvidersData?.providers, providerId]);
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
string[]
>([]);
string[] | null
>(null);
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
const lastOmoModelSourceWarningRef = useRef<string>("");
useEffect(() => {
let active = true;
if (!isOmoCategory) {
setEnabledOpencodeProviderIds([]);
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
return () => {
active = false;
};
}
setEnabledOpencodeProviderIds(null);
setOmoLiveIdsLoadFailed(false);
(async () => {
try {
const ids = await providersApi.getOpenCodeLiveProviderIds();
@@ -600,9 +710,13 @@ export function ProviderForm({
setEnabledOpencodeProviderIds(ids);
}
} catch (error) {
console.error("Failed to load OpenCode live provider ids:", error);
console.warn(
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
error,
);
if (active) {
setEnabledOpencodeProviderIds([]);
setOmoLiveIdsLoadFailed(true);
setEnabledOpencodeProviderIds(null);
}
}
})();
@@ -612,23 +726,71 @@ export function ProviderForm({
};
}, [isOmoCategory]);
const omoModelOptions = useMemo(() => {
if (!isOmoCategory) return [];
const omoModelBuild = useMemo(() => {
const empty = {
options: [] as Array<{ value: string; label: string }>,
variantsMap: {} as Record<string, string[]>,
presetMetaMap: {} as Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
>,
parseFailedProviders: [] as string[],
usedFallbackSource: false,
};
if (!isOmoCategory) {
return empty;
}
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) return [];
if (!allProviders) {
return empty;
}
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) return [];
const shouldFilterByLive = !omoLiveIdsLoadFailed;
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
return empty;
}
const liveSet =
shouldFilterByLive && enabledOpencodeProviderIds
? new Set(enabledOpencodeProviderIds)
: null;
const dedupedOptions = new Map<string, string>();
const variantsMap: Record<string, string[]> = {};
const presetMetaMap: Record<
string,
{
options?: Record<string, unknown>;
limit?: { context?: number; output?: number };
}
> = {};
const parseFailedProviders: string[] = [];
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
if (provider.category === "omo") {
continue;
}
if (liveSet && !liveSet.has(providerKey)) {
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
let parsedConfig: OpenCodeProviderConfig;
try {
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
} catch (error) {
parseFailedProviders.push(providerKey);
console.warn(
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
{
providerKey,
error,
},
);
continue;
}
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
@@ -645,63 +807,107 @@ export function ProviderForm({
if (!dedupedOptions.has(value)) {
dedupedOptions.set(value, label);
}
}
}
return Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN"));
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
]);
const omoModelVariantsMap = useMemo(() => {
const variantsMap: Record<string, string[]> = {};
if (!isOmoCategory) {
return variantsMap;
}
const allProviders = opencodeProvidersData?.providers;
if (!allProviders) {
return variantsMap;
}
const enabledSet = new Set(enabledOpencodeProviderIds);
if (enabledSet.size === 0) {
return variantsMap;
}
for (const [providerKey, provider] of Object.entries(allProviders)) {
if (provider.category === "omo" || !enabledSet.has(providerKey)) {
continue;
}
const parsedConfig = parseOpencodeConfig(provider.settingsConfig);
for (const [modelId, model] of Object.entries(
parsedConfig.models || {},
)) {
const rawVariants = model.variants;
if (
!rawVariants ||
typeof rawVariants !== "object" ||
Array.isArray(rawVariants)
rawVariants &&
typeof rawVariants === "object" &&
!Array.isArray(rawVariants)
) {
continue;
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length > 0) {
variantsMap[value] = variantKeys;
}
}
const variantKeys = Object.keys(rawVariants).filter(Boolean);
if (variantKeys.length === 0) {
continue;
}
// Preset fallback: for models without config-defined variants,
// check if the npm package has preset variant definitions.
// Also collect preset metadata (options, limit) for enrichment.
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
if (presetModels) {
for (const modelId of Object.keys(parsedConfig.models || {})) {
const fullKey = `${providerKey}/${modelId}`;
const preset = presetModels.find((p) => p.id === modelId);
if (!preset) continue;
// Variant fallback
if (!variantsMap[fullKey] && preset.variants) {
const presetKeys = Object.keys(preset.variants).filter(Boolean);
if (presetKeys.length > 0) {
variantsMap[fullKey] = presetKeys;
}
}
// Collect preset metadata for model enrichment
const meta: (typeof presetMetaMap)[string] = {};
if (preset.options) meta.options = preset.options;
if (preset.contextLimit || preset.outputLimit) {
meta.limit = {};
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
}
if (Object.keys(meta).length > 0) {
presetMetaMap[fullKey] = meta;
}
}
variantsMap[`${providerKey}/${modelId}`] = variantKeys;
}
}
return variantsMap;
return {
options: Array.from(dedupedOptions.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
variantsMap,
presetMetaMap,
parseFailedProviders,
usedFallbackSource: omoLiveIdsLoadFailed,
};
}, [
isOmoCategory,
opencodeProvidersData?.providers,
enabledOpencodeProviderIds,
omoLiveIdsLoadFailed,
]);
const omoModelOptions = omoModelBuild.options;
const omoModelVariantsMap = omoModelBuild.variantsMap;
const omoPresetMetaMap = omoModelBuild.presetMetaMap;
useEffect(() => {
if (!isOmoCategory) return;
const failed = omoModelBuild.parseFailedProviders;
const fallback = omoModelBuild.usedFallbackSource;
if (failed.length === 0 && !fallback) return;
const signature = `${fallback ? "fallback:" : ""}${failed
.slice()
.sort()
.join(",")}`;
if (lastOmoModelSourceWarningRef.current === signature) return;
lastOmoModelSourceWarningRef.current = signature;
if (failed.length > 0) {
toast.warning(
t("omo.modelSourcePartialWarning", {
count: failed.length,
defaultValue:
"Some provider model configs are invalid and were skipped.",
}),
);
}
if (fallback) {
toast.warning(
t("omo.modelSourceFallbackWarning", {
defaultValue:
"Failed to load live provider state. Falling back to configured providers.",
}),
);
}
}, [
isOmoCategory,
omoModelBuild.parseFailedProviders,
omoModelBuild.usedFallbackSource,
t,
]);
const initialOmoSettings =
@@ -777,6 +983,13 @@ export function ProviderForm({
const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false);
const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => {
const metaByApp = initialData?.meta?.commonConfigEnabledByApp;
const fromMeta =
metaByApp?.opencode ?? initialData?.meta?.commonConfigEnabled;
if (typeof fromMeta === "boolean") {
return fromMeta;
}
// Backward compatibility for old OMO data.
const raw = initialOmoSettings?.useCommonConfig;
return typeof raw === "boolean" ? raw : true;
});
@@ -860,6 +1073,21 @@ export function ProviderForm({
setUseOmoCommonConfig(useCommonConfig);
}, []);
const supportsCommonConfig =
appId === "claude" ||
appId === "codex" ||
appId === "gemini" ||
isOmoCategory;
const commonConfigEnabled = supportsCommonConfig
? appId === "claude"
? useCommonConfig
: appId === "codex"
? useCodexCommonConfigFlag
: appId === "gemini"
? useGeminiCommonConfigFlag
: useOmoCommonConfig
: undefined;
const updateOpencodeSettings = useCallback(
(updater: (config: Record<string, any>) => void) => {
try {
@@ -944,7 +1172,63 @@ export function ProviderForm({
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = (values: ProviderFormData) => {
const handleSubmit = async (values: ProviderFormData) => {
// 检查通用配置是否仍在加载中
const isCommonConfigLoading =
(appId === "claude" && isClaudeCommonConfigLoading) ||
(appId === "codex" && isCodexCommonConfigLoading) ||
(appId === "gemini" && isGeminiCommonConfigLoading);
if (isCommonConfigLoading) {
toast.error(t("providerForm.commonConfigLoading"));
return;
}
const commonConfigErrorMessage =
appId === "claude" && useCommonConfig && commonConfigError
? commonConfigError
: appId === "codex" &&
useCodexCommonConfigFlag &&
codexCommonConfigError
? codexCommonConfigError
: appId === "gemini" &&
useGeminiCommonConfigFlag &&
geminiCommonConfigError
? geminiCommonConfigError
: "";
if (commonConfigErrorMessage) {
toast.error(commonConfigErrorMessage);
return;
}
if (appId === "codex") {
if (codexAuthError) {
toast.error(codexAuthError);
return;
}
const isCodexConfigValid = validateToml(codexConfig);
if (!isCodexConfigValid) {
toast.error(
codexConfigError ||
t("mcp.error.tomlInvalid", {
defaultValue: "TOML 格式错误,请检查",
}),
);
return;
}
}
if (appId === "gemini") {
if (envError) {
toast.error(envError);
return;
}
if (geminiConfigError) {
toast.error(geminiConfigError);
return;
}
}
// 验证模板变量(仅 Claude 模式)
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
@@ -1042,14 +1326,56 @@ export function ProviderForm({
}
}
// 保存待保存的通用配置(延迟保存模式:在表单提交时统一保存)
try {
if (appId === "claude") {
const pendingSnippet = getPendingClaudeCommonConfigSnippet();
if (pendingSnippet !== null) {
await configApi.setCommonConfigSnippet("claude", pendingSnippet);
markClaudeCommonConfigSaved();
}
} else if (appId === "codex") {
const pendingSnippet = getPendingCodexCommonConfigSnippet();
if (pendingSnippet !== null) {
await configApi.setCommonConfigSnippet("codex", pendingSnippet);
markCodexCommonConfigSaved();
}
} else if (appId === "gemini") {
const pendingSnippet = getPendingGeminiCommonConfigSnippet();
if (pendingSnippet !== null) {
await configApi.setCommonConfigSnippet("gemini", pendingSnippet);
markGeminiCommonConfigSaved();
}
}
} catch (error) {
console.error("保存通用配置失败:", error);
toast.error(
t("providerForm.saveCommonConfigFailed", {
defaultValue: "保存通用配置失败",
}),
);
return;
}
let settingsConfig: string;
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
// 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置)
let configToSave = codexConfig ?? "";
if (useCodexCommonConfigFlag && codexCommonConfigSnippet.trim()) {
const { customToml, error } = extractTomlDifference(
codexConfig ?? "",
codexCommonConfigSnippet.trim(),
);
if (!error) {
configToSave = customToml;
}
}
const configObj = {
auth: authJson,
config: codexConfig ?? "",
config: configToSave,
};
settingsConfig = JSON.stringify(configObj);
} catch (err) {
@@ -1057,8 +1383,29 @@ export function ProviderForm({
}
} else if (appId === "gemini") {
try {
const envObj = envStringToObj(geminiEnv);
let envObj = envStringToObj(geminiEnv);
const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};
// 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置)
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
// Parse common config snippet (supports ENV/Flat JSON/Wrapped JSON formats)
// Uses parseGeminiCommonConfigSnippet for unified parsing
const { env: commonEnvObj, warning } = parseGeminiCommonConfig(
geminiCommonConfigSnippet.trim(),
);
// Show warning toast if keys were filtered
if (warning) {
toast.warning(mapGeminiWarningToI18n(warning, t));
}
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
const { customConfig } = extractDifference(envObj, commonEnvObj);
// Convert to string record with type guard to avoid type assertion issues
const stringEnvObj: Record<string, string> = {};
for (const [key, value] of Object.entries(customConfig)) {
stringEnvObj[key] = String(value);
}
envObj = stringEnvObj;
}
}
const combined = {
env: envObj,
config: configObj,
@@ -1069,7 +1416,6 @@ export function ProviderForm({
}
} else if (appId === "opencode" && category === "omo") {
const omoConfig: Record<string, unknown> = {};
omoConfig.useCommonConfig = useOmoCommonConfig;
if (Object.keys(omoAgents).length > 0) {
omoConfig.agents = omoAgents;
}
@@ -1078,7 +1424,19 @@ export function ProviderForm({
}
if (omoOtherFieldsStr.trim()) {
try {
omoConfig.otherFields = JSON.parse(omoOtherFieldsStr);
const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr);
if (!otherFields) {
toast.error(
t("omo.jsonMustBeObject", {
field: t("omo.otherFields", {
defaultValue: "Other Config",
}),
defaultValue: "{{field}} must be a JSON object",
}),
);
return;
}
omoConfig.otherFields = otherFields;
} catch {
toast.error(
t("omo.invalidJson", {
@@ -1090,7 +1448,28 @@ export function ProviderForm({
}
settingsConfig = JSON.stringify(omoConfig);
} else {
settingsConfig = values.settingsConfig.trim();
// Claude: 使用表单配置
// 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置)
if (useCommonConfig && commonConfigSnippet.trim()) {
try {
const currentConfig = JSON.parse(values.settingsConfig.trim());
const commonConfig = JSON.parse(commonConfigSnippet.trim());
if (isPlainObject(currentConfig) && isPlainObject(commonConfig)) {
const { customConfig } = extractDifference(
currentConfig,
commonConfig,
);
settingsConfig = JSON.stringify(customConfig, null, 2);
} else {
settingsConfig = values.settingsConfig.trim();
}
} catch {
// 如果解析失败,使用原始配置
settingsConfig = values.settingsConfig.trim();
}
} else {
settingsConfig = values.settingsConfig.trim();
}
}
const payload: ProviderFormValues = {
@@ -1168,6 +1547,16 @@ export function ProviderForm({
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
const existingCommonConfigEnabledByApp = baseMeta?.commonConfigEnabledByApp;
const commonConfigEnabledByApp = supportsCommonConfig
? ({
...(existingCommonConfigEnabledByApp ?? {}),
[appId]: commonConfigEnabled ?? false,
} as CommonConfigEnabledByApp)
: existingCommonConfigEnabledByApp;
const shouldPersistCommonConfigEnabledByApp =
supportsCommonConfig || existingCommonConfigEnabledByApp !== undefined;
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
@@ -1180,6 +1569,10 @@ export function ProviderForm({
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
...(shouldPersistCommonConfigEnabledByApp
? { commonConfigEnabledByApp }
: {}),
// Claude API 格式(仅非官方 Claude 供应商使用)
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
@@ -1603,6 +1996,7 @@ export function ProviderForm({
<OmoFormFields
modelOptions={omoModelOptions}
modelVariantsMap={omoModelVariantsMap}
presetMetaMap={omoPresetMetaMap}
agents={omoAgents}
onAgentsChange={setOmoAgents}
categories={omoCategories}
@@ -1628,6 +2022,7 @@ export function ProviderForm({
configError={codexConfigError}
onExtract={handleCodexExtract}
isExtracting={isCodexExtracting}
finalConfig={codexFinalConfig}
/>
{settingsConfigErrorField}
</>
@@ -1649,6 +2044,7 @@ export function ProviderForm({
configError={geminiConfigError}
onExtract={handleGeminiExtract}
isExtracting={isGeminiExtracting}
finalEnv={envObjToString(geminiFinalEnv)}
/>
{settingsConfigErrorField}
</>
@@ -1703,6 +2099,7 @@ export function ProviderForm({
onModalClose={() => setIsCommonConfigModalOpen(false)}
onExtract={handleClaudeExtract}
isExtracting={isClaudeExtracting}
finalConfig={claudeFinalConfig}
/>
{settingsConfigErrorField}
</>
@@ -6,9 +6,9 @@ export { useCodexConfigState } from "./useCodexConfigState";
export { useApiKeyLink } from "./useApiKeyLink";
export { useCustomEndpoints } from "./useCustomEndpoints";
export { useTemplateValues } from "./useTemplateValues";
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
export { useCodexCommonConfig } from "./useCodexCommonConfig";
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
export { useCodexTomlValidation } from "./useCodexTomlValidation";
export { useGeminiConfigState } from "./useGeminiConfigState";
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
// Common Config Hooks
export { useCodexCommonConfig } from "./useCodexCommonConfig";
@@ -1,308 +1,195 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
} from "@/utils/providerConfigUtils";
import { toast } from "sonner";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
import {
useCommonConfigBase,
type UseCommonConfigBaseReturn,
} from "@/hooks/useCommonConfigBase";
import {
codexAdapter,
preserveCodexConfigFormat,
} from "@/hooks/commonConfigAdapters";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import type { ProviderMeta } from "@/types";
interface UseCodexCommonConfigProps {
/**
* Codex TOML JSON wrapper
*/
codexConfig: string;
/**
*
*/
onConfigChange: (config: string) => void;
/**
*
*/
initialData?: {
settingsConfig?: Record<string, unknown>;
meta?: ProviderMeta;
};
/**
* ID
*/
selectedPresetId?: string;
/**
* ID
*/
currentProviderId?: string;
}
export interface UseCodexCommonConfigReturn {
/** 是否启用通用配置 */
useCommonConfig: boolean;
/** 通用配置片段 (TOML 格式) */
commonConfigSnippet: string;
/** 通用配置错误信息 */
commonConfigError: string;
/** 是否正在加载 */
isLoading: boolean;
/** 是否正在提取 */
isExtracting: boolean;
/** 通用配置开关处理函数 */
handleCommonConfigToggle: (checked: boolean) => void;
/** 通用配置片段变化处理函数 */
handleCommonConfigSnippetChange: (snippet: string) => void;
/** 从当前配置提取通用配置 */
handleExtract: () => Promise<void>;
/** 最终配置(运行时合并结果,只读) */
finalConfig: string;
/** 是否有待保存的通用配置变更 */
hasUnsavedCommonConfig: boolean;
/** 获取待保存的通用配置片段(用于 handleSubmit */
getPendingCommonConfigSnippet: () => string | null;
/** 标记通用配置已保存 */
markCommonConfigSaved: () => void;
}
/**
* Codex (TOML )
* config.json localStorage
* Codex
*
* useCommonConfigBase Hook + Codex TOML
* Codex TOML / JSON wrapper
*/
export function useCodexCommonConfig({
codexConfig,
onConfigChange,
initialData,
selectedPresetId,
}: UseCodexCommonConfigProps) {
}: UseCodexCommonConfigProps): UseCodexCommonConfigReturn {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 额外的 isExtracting 状态(base hook 的 handleExtract 不适用于 Codex
const [localIsExtracting, setLocalIsExtracting] = useState(false);
const [localExtractError, setLocalExtractError] = useState("");
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
hasInitializedNewMode.current = false;
}, [selectedPresetId]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("codex");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
// 迁移到 config.json
await configApi.setCommonConfigSnippet("codex", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载 Codex 通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, []);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData?.settingsConfig && !isLoading) {
const config =
typeof initialData.settingsConfig.config === "string"
? initialData.settingsConfig.config
: "";
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
setUseCommonConfig(hasCommon);
}
}, [initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
const lines = commonConfigSnippet.split("\n");
const hasContent = lines.some((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
if (hasContent) {
setUseCommonConfig(true);
// 合并通用配置到当前配置
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
true,
);
if (!error) {
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}
}
}, [
const base: UseCommonConfigBaseReturn<string> = useCommonConfigBase({
adapter: codexAdapter,
inputValue: codexConfig,
onInputChange: onConfigChange,
initialData,
commonConfigSnippet,
isLoading,
codexConfig,
onConfigChange,
]);
selectedPresetId,
});
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
checked,
);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[codexConfig, commonConfigSnippet, onConfigChange],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
console.error("保存 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const { updatedConfig } = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
);
onConfigChange(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
setCommonConfigError("");
// 保存到 config.json
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
console.error("保存 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.saveFailed", { error: String(error) }),
);
});
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const removeResult = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false,
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return;
}
const addResult = updateTomlCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
);
if (addResult.error) {
setCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
onConfigChange(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const hasCommon = hasTomlCommonConfigSnippet(
codexConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [codexConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
// Codex 自定义 handleExtract:处理双格式写回 + 状态管理
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
setLocalIsExtracting(true);
setLocalExtractError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("codex", {
settingsConfig: JSON.stringify({
config: codexConfig ?? "",
}),
});
const request = codexAdapter.buildExtractRequest(base.finalValue);
const extracted = await configApi.extractCommonConfigSnippet(
"codex",
request,
);
if (!extracted || !extracted.trim()) {
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
setLocalExtractError(t("codexConfig.extractNoCommonConfig"));
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 验证 TOML 格式
const parseResult = codexAdapter.parseSnippet(extracted);
if (parseResult.error || parseResult.config === null) {
setLocalExtractError(
t("codexConfig.extractedTomlInvalid", {
defaultValue: "提取的配置 TOML 格式错误",
}),
);
return;
}
// 保存到后端
await configApi.setCommonConfigSnippet("codex", extracted);
// 从 config 中移除与 extracted 相同的部分
const customToml = codexAdapter.parseInput(codexConfig);
const diffResult = extractTomlDifference(customToml, extracted);
if (diffResult.error) {
// 差异提取失败,显示错误而不是静默吞掉
setLocalExtractError(
t("codexConfig.extractDiffFailed", {
error: diffResult.error,
defaultValue: `差异提取失败: ${diffResult.error}`,
}),
);
return;
}
// 更新 snippet 状态(在差异提取成功后再更新,避免状态不一致)
base.handleCommonConfigSnippetChange(extracted);
// 使用共享的格式保留函数写回
const preserveResult = preserveCodexConfigFormat(
codexConfig,
diffResult.customToml,
);
if (preserveResult.error) {
// 格式保留失败,显示错误
setLocalExtractError(
t("codexConfig.preserveFormatFailed", {
error: preserveResult.error,
defaultValue: `配置格式保留失败: ${preserveResult.error}`,
}),
);
return;
}
onConfigChange(preserveResult.config);
toast.success(
t("codexConfig.extractSuccessNeedSave", {
defaultValue: "已提取通用配置,点击保存按钮完成保存",
}),
);
} catch (error) {
console.error("提取 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.extractFailed", { error: String(error) }),
setLocalExtractError(
t("codexConfig.extractFailed", {
error: String(error),
defaultValue: "提取失败",
}),
);
} finally {
setIsExtracting(false);
setLocalIsExtracting(false);
}
}, [codexConfig, t]);
}, [base, codexConfig, onConfigChange, t]);
// 合并 error:优先显示 extract 错误,其次是 base 的错误
const combinedError = localExtractError || base.commonConfigError;
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
useCommonConfig: base.useCommonConfig,
commonConfigSnippet: base.commonConfigSnippet,
commonConfigError: combinedError,
isLoading: base.isLoading,
isExtracting: localIsExtracting,
handleCommonConfigToggle: base.handleCommonConfigToggle,
handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange,
handleExtract,
finalConfig: base.finalValue,
hasUnsavedCommonConfig: base.hasUnsavedCommonConfig,
getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet,
markCommonConfigSaved: base.markCommonConfigSaved,
};
}
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig,
@@ -18,6 +19,7 @@ interface UseCodexConfigStateProps {
* Codex auth.json (JSON) config.toml (TOML )
*/
export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
const { t } = useTranslation();
const [codexAuth, setCodexAuthState] = useState("");
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
@@ -109,18 +111,21 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
}, [codexAuth, codexApiKey]);
// 验证 Codex Auth JSON
const validateCodexAuth = useCallback((value: string): string => {
if (!value.trim()) return "";
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "Auth JSON must be an object";
const validateCodexAuth = useCallback(
(value: string): string => {
if (!value.trim()) return "";
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return t("providerForm.authJsonRequired");
}
return "";
} catch {
return t("providerForm.authJsonError");
}
return "";
} catch {
return "Invalid JSON format";
}
}, []);
},
[t],
);
// 设置 auth 并验证
const setCodexAuth = useCallback(
@@ -1,331 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
updateCommonConfigSnippet,
hasCommonConfigSnippet,
validateJsonConfig,
} from "@/utils/providerConfigUtils";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
interface UseCommonConfigSnippetProps {
settingsConfig: string;
onConfigChange: (config: string) => void;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
/** When false, the hook skips all logic and returns disabled state. Default: true */
enabled?: boolean;
}
/**
* Claude
* config.json localStorage
*/
export function useCommonConfigSnippet({
settingsConfig,
onConfigChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigSnippetProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
}, [selectedPresetId, enabled]);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("claude");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
// 迁移到 config.json
await configApi.setCommonConfigSnippet("claude", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [enabled]);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading) {
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}
}, [enabled, initialData, commonConfigSnippet, isLoading]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
if (!enabled) return;
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
// 检查片段是否有实质内容
try {
const snippetObj = JSON.parse(commonConfigSnippet);
const hasContent = Object.keys(snippetObj).length > 0;
if (hasContent) {
setUseCommonConfig(true);
// 合并通用配置到当前配置
const { updatedConfig, error } = updateCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
true,
);
if (!error) {
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}
} catch {
// ignore parse error
}
}
}, [
enabled,
initialData,
commonConfigSnippet,
isLoading,
settingsConfig,
onConfigChange,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
checked,
);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
onConfigChange(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[settingsConfig, commonConfigSnippet, onConfigChange],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
console.error("保存通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const { updatedConfig } = updateCommonConfigSnippet(
settingsConfig,
previousSnippet,
false,
);
onConfigChange(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// 验证JSON格式
const validationError = validateJsonConfig(value, "通用配置片段");
if (validationError) {
setCommonConfigError(validationError);
} else {
setCommonConfigError("");
// 保存到 config.json
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
console.error("保存通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.saveFailed", { error: String(error) }),
);
});
}
// 若当前启用通用配置且格式正确,需要替换为最新片段
if (useCommonConfig && !validationError) {
const removeResult = updateCommonConfigSnippet(
settingsConfig,
previousSnippet,
false,
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
return;
}
const addResult = updateCommonConfigSnippet(
removeResult.updatedConfig,
value,
true,
);
if (addResult.error) {
setCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
onConfigChange(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (!enabled) return;
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const hasCommon = hasCommonConfigSnippet(
settingsConfig,
commonConfigSnippet,
);
setUseCommonConfig(hasCommon);
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("claude", {
settingsConfig,
});
if (!extracted || extracted === "{}") {
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
return;
}
// 验证 JSON 格式
const validationError = validateJsonConfig(extracted, "提取的配置");
if (validationError) {
setCommonConfigError(validationError);
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("claude", extracted);
} catch (error) {
console.error("提取通用配置失败:", error);
setCommonConfigError(
t("claudeConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [settingsConfig, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -1,465 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
] as const;
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
interface UseGeminiCommonConfigProps {
envValue: string;
onEnvChange: (env: string) => void;
envStringToObj: (envString: string) => Record<string, string>;
envObjToString: (envObj: Record<string, unknown>) => string;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
/**
* Gemini (JSON )
* Gemini .env
* - GOOGLE_GEMINI_BASE_URL
* - GEMINI_API_KEY
*/
export function useGeminiCommonConfig({
envValue,
onEnvChange,
envStringToObj,
envObjToString,
initialData,
selectedPresetId,
}: UseGeminiCommonConfigProps) {
const { t } = useTranslation();
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
useEffect(() => {
hasInitializedNewMode.current = false;
}, [selectedPresetId]);
const parseSnippetEnv = useCallback(
(
snippetString: string,
): { env: Record<string, string>; error?: string } => {
const trimmed = snippetString.trim();
if (!trimmed) {
return { env: {} };
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
if (!isPlainObject(parsed)) {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
const keys = Object.keys(parsed);
const forbiddenKeys = keys.filter((key) =>
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
);
if (forbiddenKeys.length > 0) {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidKeys", {
keys: forbiddenKeys.join(", "),
}),
};
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== "string") {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidValues"),
};
}
const normalized = value.trim();
if (!normalized) continue;
env[key] = normalized;
}
return { env };
},
[t],
);
const hasEnvCommonConfigSnippet = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const entries = Object.entries(snippetEnv);
if (entries.length === 0) return false;
return entries.every(([key, value]) => envObj[key] === value);
},
[],
);
const applySnippetToEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string") {
updated[key] = value;
}
}
return updated;
},
[],
);
const removeSnippetFromEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string" && updated[key] === value) {
delete updated[key];
}
}
return updated;
},
[],
);
// 初始化:从 config.json 加载,支持从 localStorage 迁移
useEffect(() => {
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("gemini");
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else {
// 如果 config.json 中没有,尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
const parsed = parseSnippetEnv(legacySnippet);
if (parsed.error) {
console.warn(
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
);
return;
}
// 迁移到 config.json
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
}
} catch (error) {
console.error("加载 Gemini 通用配置失败:", error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [parseSnippetEnv]);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData?.settingsConfig && !isLoading) {
try {
const env =
isPlainObject(initialData.settingsConfig.env) &&
Object.keys(initialData.settingsConfig.env).length > 0
? (initialData.settingsConfig.env as Record<string, string>)
: {};
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const hasCommon = hasEnvCommonConfigSnippet(
env,
parsed.env as Record<string, string>,
);
setUseCommonConfig(hasCommon);
} catch {
// ignore parse error
}
}
}, [
commonConfigSnippet,
hasEnvCommonConfigSnippet,
initialData,
isLoading,
parseSnippetEnv,
]);
// 新建模式:如果通用配置片段存在且有效,默认启用
useEffect(() => {
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const hasContent = Object.keys(parsed.env).length > 0;
if (!hasContent) return;
setUseCommonConfig(true);
const currentEnv = envStringToObj(envValue);
const merged = applySnippetToEnv(currentEnv, parsed.env);
const nextEnvString = envObjToString(merged);
isUpdatingFromCommonConfig.current = true;
onEnvChange(nextEnvString);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
}, [
initialData,
isLoading,
commonConfigSnippet,
envValue,
envStringToObj,
envObjToString,
applySnippetToEnv,
onEnvChange,
parseSnippetEnv,
]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) {
setCommonConfigError(parsed.error);
setUseCommonConfig(false);
return;
}
if (Object.keys(parsed.env).length === 0) {
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
setUseCommonConfig(false);
return;
}
const currentEnv = envStringToObj(envValue);
const updatedEnvObj = checked
? applySnippetToEnv(currentEnv, parsed.env)
: removeSnippetFromEnv(currentEnv, parsed.env);
setCommonConfigError("");
setUseCommonConfig(checked);
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(updatedEnvObj));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[
applySnippetToEnv,
commonConfigSnippet,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
parseSnippetEnv,
removeSnippetFromEnv,
t,
],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
// 保存到 config.json(清空)
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const parsed = parseSnippetEnv(previousSnippet);
if (!parsed.error && Object.keys(parsed.env).length > 0) {
const currentEnv = envStringToObj(envValue);
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
onEnvChange(envObjToString(updatedEnv));
}
setUseCommonConfig(false);
}
return;
}
// 校验 JSON 格式
const parsed = parseSnippetEnv(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
return;
}
setCommonConfigError("");
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const prevParsed = parseSnippetEnv(previousSnippet);
const prevEnv = prevParsed.error ? {} : prevParsed.env;
const nextEnv = parsed.env;
const currentEnv = envStringToObj(envValue);
const withoutOld =
Object.keys(prevEnv).length > 0
? removeSnippetFromEnv(currentEnv, prevEnv)
: currentEnv;
const withNew =
Object.keys(nextEnv).length > 0
? applySnippetToEnv(withoutOld, nextEnv)
: withoutOld;
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(withNew));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
},
[
applySnippetToEnv,
commonConfigSnippet,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
parseSnippetEnv,
removeSnippetFromEnv,
t,
useCommonConfig,
],
);
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const envObj = envStringToObj(envValue);
setUseCommonConfig(
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
);
}, [
envValue,
commonConfigSnippet,
envStringToObj,
hasEnvCommonConfigSnippet,
isLoading,
parseSnippetEnv,
]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
settingsConfig: JSON.stringify({
env: envStringToObj(envValue),
}),
});
if (!extracted || extracted === "{}") {
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
return;
}
// 验证 JSON 格式
const parsed = parseSnippetEnv(extracted);
if (parsed.error) {
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("gemini", extracted);
} catch (error) {
console.error("提取 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [envStringToObj, envValue, parseSnippetEnv, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
interface UseGeminiConfigStateProps {
initialData?: {
@@ -13,6 +14,7 @@ interface UseGeminiConfigStateProps {
export function useGeminiConfigState({
initialData,
}: UseGeminiConfigStateProps) {
const { t } = useTranslation();
const [geminiEnv, setGeminiEnvState] = useState("");
const [geminiConfig, setGeminiConfigState] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
@@ -119,18 +121,21 @@ export function useGeminiConfigState({
}, [geminiEnv, envStringToObj, geminiApiKey, geminiBaseUrl, geminiModel]);
// 验证 Gemini Config JSON
const validateGeminiConfig = useCallback((value: string): string => {
if (!value.trim()) return ""; // 空值允许
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return "";
const validateGeminiConfig = useCallback(
(value: string): string => {
if (!value.trim()) return ""; // 空值允许
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return "";
}
return t("providerForm.configJsonError");
} catch {
return t("providerForm.configJsonError");
}
return "Config must be a JSON object";
} catch {
return "Invalid JSON format";
}
}, []);
},
[t],
);
// 设置 env
const setGeminiEnv = useCallback((value: string) => {
@@ -10,6 +10,7 @@ export function RectifierConfigPanel() {
const [config, setConfig] = useState<RectifierConfig>({
enabled: true,
requestThinkingSignature: true,
requestThinkingBudget: true,
});
const [isLoading, setIsLoading] = useState(true);
@@ -69,6 +70,21 @@ export function RectifierConfigPanel() {
}
/>
</div>
<div className="flex items-center justify-between pl-4">
<div className="space-y-0.5">
<Label>{t("settings.advanced.rectifier.thinkingBudget")}</Label>
<p className="text-xs text-muted-foreground">
{t("settings.advanced.rectifier.thinkingBudgetDescription")}
</p>
</div>
<Switch
checked={config.requestThinkingBudget}
disabled={!config.enabled}
onCheckedChange={(checked) =>
handleChange({ requestThinkingBudget: checked })
}
/>
</div>
</div>
</div>
);
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b px-3 focus-within:outline-none focus-within:ring-0"
cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
};
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "start", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+382 -1
View File
@@ -24,6 +24,384 @@ export const opencodeNpmPackages = [
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
] as const;
export interface PresetModelVariant {
id: string;
name?: string;
contextLimit?: number;
outputLimit?: number;
modalities?: { input: string[]; output: string[] };
options?: Record<string, unknown>;
variants?: Record<string, Record<string, unknown>>;
}
export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
string,
PresetModelVariant[]
> = {
"@ai-sdk/openai-compatible": [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "glm-4.7",
name: "GLM 4.7",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
contextLimit: 262144,
outputLimit: 262144,
modalities: { input: ["text", "image", "video"], output: ["text"] },
},
],
"@ai-sdk/google": [
{
id: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash Lite",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
auto: {
thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },
},
"no-thinking": { thinkingConfig: { thinkingBudget: 0 } },
},
},
{
id: "gemini-3-flash-preview",
name: "Gemini 3 Flash Preview",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
minimal: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" },
},
low: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
},
medium: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "medium" },
},
high: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
},
},
},
{
id: "gemini-3-pro-preview",
name: "Gemini 3 Pro Preview",
contextLimit: 1048576,
outputLimit: 65536,
modalities: {
input: ["text", "image", "pdf", "video", "audio"],
output: ["text"],
},
variants: {
low: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "low" },
},
high: {
thinkingConfig: { includeThoughts: true, thinkingLevel: "high" },
},
},
},
],
"@ai-sdk/openai": [
{
id: "gpt-5",
name: "GPT-5",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1",
name: "GPT-5.1",
contextLimit: 400000,
outputLimit: 272000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "low",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "high",
},
},
},
{
id: "gpt-5.1-codex",
name: "GPT-5.1 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.1-codex-max",
name: "GPT-5.1 Codex Max",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2",
name: "GPT-5.2",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
{
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
contextLimit: 400000,
outputLimit: 128000,
modalities: { input: ["text", "image"], output: ["text"] },
options: { include: ["reasoning.encrypted_content"], store: false },
variants: {
low: {
reasoningEffort: "low",
reasoningSummary: "auto",
textVerbosity: "medium",
},
medium: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
high: {
reasoningEffort: "high",
reasoningSummary: "auto",
textVerbosity: "medium",
},
xhigh: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
},
],
"@ai-sdk/anthropic": [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
},
},
{
id: "claude-opus-4-5-20251101",
name: "Claude Opus 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
},
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextLimit: 1000000,
outputLimit: 128000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
max: { effort: "max" },
},
},
{
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
{
id: "gemini-claude-opus-4-5-thinking",
name: "Antigravity - Claude Opus 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { effort: "low" },
medium: { effort: "medium" },
high: { effort: "high" },
},
},
{
id: "gemini-claude-sonnet-4-5-thinking",
name: "Antigravity - Claude Sonnet 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinking: { budgetTokens: 5000, type: "enabled" } },
medium: { thinking: { budgetTokens: 13000, type: "enabled" } },
high: { thinking: { budgetTokens: 18000, type: "enabled" } },
},
},
],
};
/**
* Look up preset metadata for a model by npm package and model ID.
* Returns enrichment fields (options, limit, modalities) that can be
* merged into a model definition when the user's config doesn't already
* provide them.
*/
export function getPresetModelDefaults(
npm: string,
modelId: string,
): PresetModelVariant | undefined {
const models = OPENCODE_PRESET_MODEL_VARIANTS[npm];
if (!models) return undefined;
return models.find((m) => m.id === modelId);
}
export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
{
name: "DeepSeek",
@@ -664,7 +1042,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
models: {
"gpt-5.2": { name: "GPT-5.2" },
"gpt-5.2-codex": { name: "GPT-5.2 Codex" },
"gpt-5.2-codex": {
name: "GPT-5.2 Codex",
options: { include: ["reasoning.encrypted_content"], store: false },
},
},
},
category: "third_party",
+504
View File
@@ -0,0 +1,504 @@
/**
*
*
* Claude (JSON), Codex (TOML), Gemini (ENV/JSON)
*/
import {
computeFinalConfig,
extractDifference,
isPlainObject,
isSubset,
} from "@/utils/configMerge";
import {
computeFinalTomlConfig,
extractTomlDifference,
safeParseToml,
} from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
GEMINI_CONFIG_ERROR_CODES,
} from "@/utils/providerConfigUtils";
import type {
CommonConfigAdapter,
ParseResult,
ExtractResult,
} from "./useCommonConfigBase";
// Re-export from utils for backward compatibility
export { hasContentByAppType } from "@/utils/commonConfigDetection";
import { hasGeminiContent } from "@/utils/commonConfigDetection";
// ============================================================================
// Claude Adapter (JSON)
// ============================================================================
const CLAUDE_LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
const CLAUDE_DEFAULT_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
export const claudeAdapter: CommonConfigAdapter<
Record<string, unknown>,
string
> = {
appKey: "claude",
defaultSnippet: CLAUDE_DEFAULT_SNIPPET,
legacyStorageKey: CLAUDE_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<Record<string, unknown>> => {
const trimmed = snippet.trim();
if (!trimmed) {
return { config: {}, error: null };
}
try {
const parsed = JSON.parse(trimmed);
if (!isPlainObject(parsed)) {
return { config: null, error: "JSON 格式错误:不是对象" };
}
return { config: parsed, error: null };
} catch {
return { config: null, error: "JSON 格式错误" };
}
},
hasValidContent: (snippet: string): boolean => {
const result = claudeAdapter.parseSnippet(snippet);
return (
!result.error &&
result.config !== null &&
Object.keys(result.config).length > 0
);
},
hasContent: (configStr: string, snippetStr: string): boolean => {
try {
if (!snippetStr.trim()) return false;
const config = configStr ? JSON.parse(configStr) : {};
const snippet = JSON.parse(snippetStr);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch {
return false;
}
},
getApplyError: (snippet: string, t): string => {
const result = claudeAdapter.parseSnippet(snippet);
if (result.error) {
return result.error;
}
if (result.config === null || Object.keys(result.config).length === 0) {
return t("claudeConfig.noCommonConfigToApply");
}
return "";
},
parseInput: (input: string): Record<string, unknown> => {
try {
const parsed = JSON.parse(input || "{}");
return isPlainObject(parsed) ? parsed : {};
} catch {
return {};
}
},
computeFinal: (
custom: Record<string, unknown>,
common: Record<string, unknown>,
enabled: boolean,
): string => {
if (!enabled || Object.keys(common).length === 0) {
return JSON.stringify(custom, null, 2);
}
const merged = computeFinalConfig(custom, common, true);
return JSON.stringify(merged, null, 2);
},
extractDiff: (
custom: Record<string, unknown>,
common: Record<string, unknown>,
): ExtractResult<Record<string, unknown>> => {
const result = extractDifference(custom, common);
return {
custom: result.customConfig,
hasCommonKeys: result.hasCommonKeys,
};
},
serializeOutput: (config: Record<string, unknown>): string => {
return JSON.stringify(config, null, 2);
},
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
return { settingsConfig: finalValue };
},
};
// ============================================================================
// Codex Adapter (TOML)
// ============================================================================
const CODEX_LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const CODEX_DEFAULT_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
/** 检查 TOML 是否有实质内容(非空、非纯注释) */
function hasTomlContent(toml: string): boolean {
const lines = toml.split("\n");
return lines.some((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
}
/** 校验 TOML 格式 */
function validateTomlFormat(tomlText: string): string | null {
if (!hasTomlContent(tomlText)) {
return null; // 空或纯注释是合法的
}
const result = safeParseToml(tomlText);
return result.error;
}
/**
* codexConfig config TOML
*
* 1. TOML
* 2. JSON { auth: {...}, config: "TOML" }
*
* config string schema
*
*/
function extractConfigToml(configInput: string): string {
if (!configInput || !configInput.trim()) {
return "";
}
// 尝试解析为 JSONwrapper 格式)
try {
const parsed = JSON.parse(configInput);
if (typeof parsed?.config === "string") {
return parsed.config;
}
// 如果是 JSON 对象
if (typeof parsed === "object" && parsed !== null) {
// config 字段存在但不是 string,这是 schema 异常
if ("config" in parsed && typeof parsed.config !== "string") {
console.warn(
`[extractConfigToml] config field is ${typeof parsed.config}, expected string`,
);
}
// JSON 对象没有有效的 config 字段,返回空(无 TOML 可提取)
return "";
}
} catch {
// JSON 解析失败,说明是直接的 TOML 字符串
}
return configInput;
}
/**
* Codex JSON wrapper
* @returns JSON wrapper null
*/
function detectJsonWrapperFormat(
codexConfig: string,
): { auth?: unknown; config?: unknown; [key: string]: unknown } | null {
try {
const parsed = JSON.parse(codexConfig);
// 只有当解析结果是对象时才认为是 JSON wrapper
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
return parsed;
}
} catch {
// 不是 JSON
}
return null;
}
/**
* Codex
*/
export interface PreserveCodexConfigResult {
/** 格式化后的配置字符串 */
config: string;
/** 错误信息(如果有) */
error?: string;
}
/**
* Codex
*
* JSON wrapper config JSON
* TOML TOML
*
* @param originalConfig -
* @param updatedToml - TOML
* @returns
*/
export function preserveCodexConfigFormat(
originalConfig: string,
updatedToml: string,
): PreserveCodexConfigResult {
const jsonWrapper = detectJsonWrapperFormat(originalConfig);
if (jsonWrapper) {
// 是 JSON wrapper 格式
if ("config" in jsonWrapper) {
// 存在 config 字段
if (typeof jsonWrapper.config !== "string") {
// config 字段不是 string,这是意外的 schema
// 返回原配置并报错,避免数据丢失
return {
config: originalConfig,
error: `Codex config field is ${typeof jsonWrapper.config}, expected string. Cannot update safely.`,
};
}
// 正常更新 config 字段
jsonWrapper.config = updatedToml;
return { config: JSON.stringify(jsonWrapper, null, 2) };
} else {
// 没有 config 字段,但是是 JSON 对象(可能包含 auth 等其他字段)
// 添加 config 字段
jsonWrapper.config = updatedToml;
return { config: JSON.stringify(jsonWrapper, null, 2) };
}
}
// 纯 TOML 格式
return { config: updatedToml };
}
export const codexAdapter: CommonConfigAdapter<string, string> = {
appKey: "codex",
defaultSnippet: CODEX_DEFAULT_SNIPPET,
legacyStorageKey: CODEX_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<string> => {
const error = validateTomlFormat(snippet);
if (error) {
return { config: null, error };
}
return { config: snippet, error: null };
},
hasValidContent: (snippet: string): boolean => {
return hasTomlContent(snippet) && !validateTomlFormat(snippet);
},
hasContent: (configStr: string, snippetStr: string): boolean => {
if (!snippetStr.trim()) return false;
// 解析配置(可能是纯 TOML 或 JSON wrapper 格式)
const configToml = extractConfigToml(configStr);
const configParsed = safeParseToml(configToml);
const snippetParsed = safeParseToml(snippetStr);
if (configParsed.error || !configParsed.config) return false;
if (snippetParsed.error || !snippetParsed.config) return false;
// 使用 isSubset 检查 snippet 是否是 config 的子集
return isSubset(configParsed.config, snippetParsed.config);
},
getApplyError: (snippet: string, t): string => {
if (!hasTomlContent(snippet)) {
return t("codexConfig.noCommonConfigToApply");
}
const error = validateTomlFormat(snippet);
if (error) {
return t("codexConfig.tomlFormatError", {
defaultValue: "TOML 格式错误",
});
}
return "";
},
parseInput: (input: string): string => {
return extractConfigToml(input);
},
computeFinal: (custom: string, common: string, enabled: boolean): string => {
if (!enabled || !hasTomlContent(common)) {
return custom;
}
const result = computeFinalTomlConfig(custom, common, true);
return result.error ? custom : result.finalConfig;
},
extractDiff: (custom: string, common: string): ExtractResult<string> => {
const result = extractTomlDifference(custom, common);
return {
custom: result.customToml,
hasCommonKeys: result.hasCommonKeys,
error: result.error,
};
},
serializeOutput: (config: string): string => {
return config;
},
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
return {
settingsConfig: JSON.stringify({ config: finalValue ?? "" }),
};
},
};
// ============================================================================
// Gemini Adapter (ENV/JSON)
// ============================================================================
const GEMINI_LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const GEMINI_DEFAULT_SNIPPET = "";
export interface GeminiAdapterOptions {
/** 字符串转对象 */
envStringToObj: (envString: string) => Record<string, string>;
/** 对象转字符串 */
envObjToString: (envObj: Record<string, unknown>) => string;
}
/**
* Gemini
* env
*/
export function createGeminiAdapter(
options: GeminiAdapterOptions,
): CommonConfigAdapter<Record<string, string>, Record<string, string>> {
const { envStringToObj, envObjToString } = options;
return {
appKey: "gemini",
defaultSnippet: GEMINI_DEFAULT_SNIPPET,
legacyStorageKey: GEMINI_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<Record<string, string>> => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
if (result.error) {
return { config: null, error: result.error };
}
return { config: result.env, error: null };
},
hasValidContent: (snippet: string): boolean => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
return !result.error && Object.keys(result.env).length > 0;
},
hasContent: (configStr: string, snippetStr: string): boolean => {
return hasGeminiContent(configStr, snippetStr).hasContent;
},
getApplyError: (snippet: string, t): string => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
if (result.errorInfo) {
// Use structured error info for type-safe handling
switch (result.errorInfo.code) {
case GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS:
return t("geminiConfig.commonConfigInvalidKeys", {
keys: result.errorInfo.keys?.join(", ") ?? "",
});
case GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING:
return t("geminiConfig.commonConfigInvalidValues");
default:
return t("geminiConfig.invalidEnvFormat", {
defaultValue: "配置格式错误",
});
}
}
// Fallback for legacy error string (backward compatibility)
if (result.error) {
return t("geminiConfig.invalidEnvFormat", {
defaultValue: "配置格式错误",
});
}
if (Object.keys(result.env).length === 0) {
return t("geminiConfig.noCommonConfigToApply");
}
return "";
},
parseInput: (input: string): Record<string, string> => {
return envStringToObj(input);
},
computeFinal: (
custom: Record<string, string>,
common: Record<string, string>,
enabled: boolean,
): Record<string, string> => {
if (!enabled || Object.keys(common).length === 0) {
return custom;
}
// 通用配置作为 base,自定义 env 覆盖
const merged = computeFinalConfig(
custom as Record<string, unknown>,
common as Record<string, unknown>,
true,
);
// 转换回 Record<string, string>
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(merged)) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
},
extractDiff: (
custom: Record<string, string>,
common: Record<string, string>,
): ExtractResult<Record<string, string>> => {
const result = extractDifference(
custom as Record<string, unknown>,
common as Record<string, unknown>,
);
// 转换回 Record<string, string>
const customResult: Record<string, string> = {};
for (const [key, value] of Object.entries(result.customConfig)) {
if (typeof value === "string") {
customResult[key] = value;
}
}
return {
custom: customResult,
hasCommonKeys: result.hasCommonKeys,
};
},
serializeOutput: (config: Record<string, string>): string => {
return envObjToString(config);
},
buildExtractRequest: (
finalValue: Record<string, string>,
): { settingsConfig: string } => {
return {
settingsConfig: JSON.stringify({ env: finalValue }),
};
},
};
}
+494
View File
@@ -0,0 +1,494 @@
/**
* Hook
*
* Adapter
* Claude (JSON), Codex (TOML), Gemini (ENV/JSON)
*/
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { configApi } from "@/lib/api";
import type { ProviderMeta } from "@/types";
// ============================================================================
// 类型定义
// ============================================================================
/** 应用类型 */
export type CommonConfigAppKey = "claude" | "codex" | "gemini";
/** 解析结果 */
export interface ParseResult<T> {
config: T | null;
error: string | null;
}
/** 合并结果 */
export interface MergeResult<T> {
merged: T;
error?: string;
}
/** 差异提取结果 */
export interface ExtractResult<T> {
custom: T;
hasCommonKeys: boolean;
error?: string;
}
/**
*
*
* JSON/TOML/ENV
*/
export interface CommonConfigAdapter<TConfig, TFinal> {
/** 应用标识 */
appKey: CommonConfigAppKey;
/** 默认片段内容 */
defaultSnippet: string;
/** localStorage 迁移 key (可选) */
legacyStorageKey?: string;
/**
*
* @param snippet -
* @returns
*/
parseSnippet: (snippet: string) => ParseResult<TConfig>;
/**
*
* @param snippet -
* @returns
*/
hasValidContent: (snippet: string) => boolean;
/**
*
*
* @param configStr - (settingsConfig)
* @param snippetStr -
* @returns
*/
hasContent: (configStr: string, snippetStr: string) => boolean;
/**
* toggle
* @param snippet -
* @param t - i18n
* @returns
*/
getApplyError: (
snippet: string,
t: (key: string, options?: Record<string, unknown>) => string,
) => string;
/**
*
* @param input -
* @returns
*/
parseInput: (input: string) => TConfig;
/**
*
* @param custom -
* @param common -
* @param enabled -
* @returns
*/
computeFinal: (custom: TConfig, common: TConfig, enabled: boolean) => TFinal;
/**
*
* @param custom -
* @param common -
* @returns
*/
extractDiff: (custom: TConfig, common: TConfig) => ExtractResult<TConfig>;
/**
*
* @param config -
* @returns
*/
serializeOutput: (config: TConfig) => string;
/**
* API
* @param finalValue -
* @returns API
*/
buildExtractRequest: (finalValue: TFinal) => { settingsConfig: string };
}
// ============================================================================
// Props 和 Return 类型
// ============================================================================
export interface UseCommonConfigBaseProps<TConfig, TFinal> {
/** 格式适配器 */
adapter: CommonConfigAdapter<TConfig, TFinal>;
/** 当前表单输入值 */
inputValue: string;
/** 输入变化回调 */
onInputChange: (value: string) => void;
/** 初始数据(编辑模式) */
initialData?: {
settingsConfig?: Record<string, unknown>;
meta?: ProviderMeta;
};
/** 当前选中的预设 ID */
selectedPresetId?: string;
/** 是否启用此 hook(默认 true */
enabled?: boolean;
}
export interface UseCommonConfigBaseReturn<TFinal> {
/** 是否启用通用配置 */
useCommonConfig: boolean;
/** 通用配置片段 */
commonConfigSnippet: string;
/** 通用配置错误信息 */
commonConfigError: string;
/** 是否正在加载 */
isLoading: boolean;
/** 是否正在提取 */
isExtracting: boolean;
/** 通用配置开关处理函数 */
handleCommonConfigToggle: (checked: boolean) => void;
/** 通用配置片段变化处理函数 */
handleCommonConfigSnippetChange: (snippet: string) => void;
/** 从当前配置提取通用配置 */
handleExtract: () => Promise<void>;
/** 最终配置(运行时合并结果,只读) */
finalValue: TFinal;
/** 是否有待保存的通用配置变更 */
hasUnsavedCommonConfig: boolean;
/** 获取待保存的通用配置片段(用于 handleSubmit */
getPendingCommonConfigSnippet: () => string | null;
/** 标记通用配置已保存 */
markCommonConfigSaved: () => void;
}
// ============================================================================
// 基础 Hook 实现
// ============================================================================
export function useCommonConfigBase<TConfig, TFinal>({
adapter,
inputValue,
onInputChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigBaseProps<
TConfig,
TFinal
>): UseCommonConfigBaseReturn<TFinal> {
const { t } = useTranslation();
// ============================================================================
// 状态
// ============================================================================
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
adapter.defaultSnippet,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false);
// 初始化跟踪
const hasInitializedEditMode = useRef(false);
const hasInitializedNewMode = useRef(false);
// ============================================================================
// 预设变化时重置初始化标记
// ============================================================================
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
hasInitializedEditMode.current = false;
}, [selectedPresetId, enabled]);
// ============================================================================
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
// ============================================================================
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
try {
const snippet = await configApi.getCommonConfigSnippet(adapter.appKey);
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else if (adapter.legacyStorageKey && typeof window !== "undefined") {
// 尝试从 localStorage 迁移
try {
const legacySnippet = window.localStorage.getItem(
adapter.legacyStorageKey,
);
if (legacySnippet && legacySnippet.trim()) {
const parsed = adapter.parseSnippet(legacySnippet);
// 只有在解析成功且有有效内容时才迁移
// 这避免了将 "{}" 这样的空 JSON 迁移为"有效配置"
if (!parsed.error && adapter.hasValidContent(legacySnippet)) {
await configApi.setCommonConfigSnippet(
adapter.appKey,
legacySnippet,
);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
window.localStorage.removeItem(adapter.legacyStorageKey);
console.log(
`[迁移] ${adapter.appKey} 通用配置已从 localStorage 迁移到数据库`,
);
} else {
// 解析失败或无有效内容,清理 localStorage 不迁移
window.localStorage.removeItem(adapter.legacyStorageKey);
}
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
} catch (error) {
console.error(`加载 ${adapter.appKey} 通用配置失败:`, error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [enabled, adapter]);
// ============================================================================
// 编辑模式初始化:从 meta 读取启用状态
// ============================================================================
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading && !hasInitializedEditMode.current) {
hasInitializedEditMode.current = true;
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
const resolvedMetaEnabled =
metaByApp?.[adapter.appKey] ?? initialData.meta?.commonConfigEnabled;
if (resolvedMetaEnabled !== undefined) {
if (!resolvedMetaEnabled) {
setUseCommonConfig(false);
return;
}
const applyError = adapter.getApplyError(commonConfigSnippet, t);
if (applyError) {
setCommonConfigError(applyError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(true);
} else {
setUseCommonConfig(false);
}
}
}, [enabled, initialData, isLoading, commonConfigSnippet, adapter, t]);
// ============================================================================
// 新建模式初始化:如果通用配置有效,默认启用
// ============================================================================
useEffect(() => {
if (!enabled) return;
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
if (adapter.hasValidContent(commonConfigSnippet)) {
const parsed = adapter.parseSnippet(commonConfigSnippet);
if (!parsed.error && parsed.config !== null) {
setUseCommonConfig(true);
}
}
}
}, [enabled, initialData, commonConfigSnippet, isLoading, adapter]);
// ============================================================================
// 计算最终配置(运行时合并)
// ============================================================================
const finalValue = useMemo((): TFinal => {
const customConfig = adapter.parseInput(inputValue);
if (!enabled || !useCommonConfig) {
return adapter.computeFinal(customConfig, customConfig, false);
}
const snippetParsed = adapter.parseSnippet(commonConfigSnippet);
if (snippetParsed.error || snippetParsed.config === null) {
return adapter.computeFinal(customConfig, customConfig, false);
}
return adapter.computeFinal(customConfig, snippetParsed.config, true);
}, [enabled, inputValue, commonConfigSnippet, useCommonConfig, adapter]);
// ============================================================================
// 处理通用配置开关
// ============================================================================
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
if (checked) {
const applyError = adapter.getApplyError(commonConfigSnippet, t);
if (applyError) {
setCommonConfigError(applyError);
setUseCommonConfig(false);
return;
}
}
setCommonConfigError("");
setUseCommonConfig(checked);
},
[commonConfigSnippet, adapter, t],
);
// ============================================================================
// 处理通用配置片段变化(延迟保存模式)
// ============================================================================
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
setHasUnsavedCommonConfig(true);
return;
}
// 格式校验
const parsed = adapter.parseSnippet(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
return;
}
setCommonConfigError("");
setHasUnsavedCommonConfig(true);
},
[adapter],
);
// ============================================================================
// 从当前最终配置提取通用配置片段
// ============================================================================
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const request = adapter.buildExtractRequest(finalValue);
const extracted = await configApi.extractCommonConfigSnippet(
adapter.appKey,
request,
);
if (
!extracted ||
!extracted.trim() ||
!adapter.hasValidContent(extracted)
) {
setCommonConfigError(
t(`${adapter.appKey}Config.extractNoCommonConfig`, {
defaultValue: "无法提取通用配置",
}),
);
return;
}
// 验证提取结果格式
const extractedParsed = adapter.parseSnippet(extracted);
if (extractedParsed.error || extractedParsed.config === null) {
setCommonConfigError(
t(`${adapter.appKey}Config.extractedConfigInvalid`, {
defaultValue: "提取的配置格式错误",
}),
);
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
setHasUnsavedCommonConfig(true);
// 从自定义配置中移除与提取内容相同的部分
const customConfig = adapter.parseInput(inputValue);
const diffResult = adapter.extractDiff(
customConfig,
extractedParsed.config,
);
if (!diffResult.error) {
onInputChange(adapter.serializeOutput(diffResult.custom));
toast.success(
t(`${adapter.appKey}Config.extractSuccessNeedSave`, {
defaultValue: "已提取通用配置,点击保存按钮完成保存",
}),
);
}
} catch (error) {
console.error(`提取 ${adapter.appKey} 通用配置失败:`, error);
setCommonConfigError(
t(`${adapter.appKey}Config.extractFailed`, {
error: String(error),
defaultValue: "提取失败",
}),
);
} finally {
setIsExtracting(false);
}
}, [adapter, finalValue, inputValue, onInputChange, t]);
// ============================================================================
// 获取待保存的通用配置片段
// ============================================================================
const getPendingCommonConfigSnippet = useCallback(() => {
return hasUnsavedCommonConfig ? commonConfigSnippet : null;
}, [hasUnsavedCommonConfig, commonConfigSnippet]);
// ============================================================================
// 标记通用配置已保存
// ============================================================================
const markCommonConfigSaved = useCallback(() => {
setHasUnsavedCommonConfig(false);
}, []);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
finalValue,
hasUnsavedCommonConfig,
getPendingCommonConfigSnippet,
markCommonConfigSaved,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { useState, useEffect } from "react";
/**
* Hook to track dark mode state by observing document.documentElement class changes.
*
* @returns boolean indicating if dark mode is currently active
*/
export function useDarkMode(): boolean {
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return isDarkMode;
}
+80 -11
View File
@@ -37,7 +37,8 @@
"search": "Search",
"reset": "Reset",
"actions": "Actions",
"deleting": "Deleting..."
"deleting": "Deleting...",
"readonly": "Read-only"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -54,11 +55,18 @@
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Common Config Snippet",
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
"fullSettingsHint": "Full Claude Code settings.json content",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}"
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
"saveFailed": "Save failed: {{error}}",
"hidePreview": "Hide merged preview",
"showPreview": "Show merged preview",
"customConfig": "Custom config (overrides common)",
"mergedPreview": "Merged preview (read-only)",
"mergedPreviewHint": "Common config + Custom config = Final config"
},
"header": {
"viewOnGithub": "View on GitHub",
@@ -214,7 +222,9 @@
"requestGroup": "Request Rectification",
"responseGroup": "Response Rectification",
"thinkingSignature": "Thinking Signature Rectification",
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
"thinkingSignatureDescription": "When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider",
"thinkingBudget": "Thinking Budget Rectification",
"thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once"
},
"logConfig": {
"title": "Log Management",
@@ -536,7 +546,10 @@
"categoryOfficial": "Official",
"categoryCnOfficial": "Opensource Official",
"categoryAggregation": "Aggregation",
"categoryThirdParty": "Third Party"
"categoryThirdParty": "Third Party",
"commonConfigLoading": "Common config is still loading, please wait before saving",
"commonConfigSyncFailed": "Common config sync failed for some providers. Please retry.",
"saveCommonConfigFailed": "Failed to save common config"
},
"endpointTest": {
"title": "API Endpoint Management",
@@ -601,10 +614,12 @@
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
"commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked",
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
"apiUrlLabel": "API Request URL",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
"saveFailed": "Save failed: {{error}}"
},
"geminiConfig": {
@@ -619,14 +634,17 @@
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"extractSuccessNeedSave": "Common config extracted, click Save to complete",
"saveFailed": "Save failed: {{error}}",
"extractedConfigInvalid": "Extracted config format is invalid",
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
"invalidEnvFormat": "Config format error (use KEY=VALUE format, one per line)",
"commonConfigInvalidKeys": "Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})",
"commonConfigInvalidValues": "Common config snippet values must be strings",
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
"configMergeFailed": "Config merge failed: {{error}}",
"configReplaceFailed": "Config replace failed: {{error}}"
"configReplaceFailed": "Config replace failed: {{error}}",
"forbiddenKeysWarning": "The following keys were filtered (not allowed in common config): {{keys}}"
},
"opencode": {
"npmPackage": "API Format",
@@ -1572,16 +1590,21 @@
"clearWrapped": "(Clear)",
"defaultWrapped": "(Default)",
"variantPlaceholder": "variant",
"selectEnabledModel": "Select enabled model",
"selectEnabledModel": "Select configured model",
"selectModel": "Select configured model",
"recommendedHint": "Recommended: {{model}}",
"searchModel": "Search model...",
"selectModelFirst": "Select model first",
"noEnabledModels": "No enabled models",
"noEnabledModels": "No configured models",
"noVariantsForModel": "No variants for model",
"currentValueNotEnabled": "{{value}} (current value, not enabled)",
"currentValueNotEnabled": "{{value}} (current value, not configured)",
"currentValueUnavailable": "{{value}} (current value, unavailable)",
"advancedLabel": "Advanced",
"advancedJsonInvalid": "Advanced JSON is invalid",
"advancedJsonHint": "temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults",
"noEnabledModelsWarning": "No enabled models available. Configure and enable OpenCode models first.",
"noEnabledModelsWarning": "No configured models available. Configure OpenCode models first.",
"modelSourcePartialWarning": "Some provider model configs are invalid and were skipped.",
"modelSourceFallbackWarning": "Failed to load live provider state. Falling back to configured providers.",
"importLocalReplaceSuccess": "Imported local file and replaced Agents/Categories/Other Fields",
"importLocalFailed": "Failed to read local file: {{error}}",
"agentKeyPlaceholder": "agent key",
@@ -1592,7 +1615,7 @@
"modelConfiguration": "Model Configuration",
"fillRecommended": "Fill Recommended",
"configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
"enabledModelsCount": "{{count}} enabled models available",
"enabledModelsCount": "{{count}} configured models available",
"source": "from:",
"otherFieldsJson": "Other Fields (JSON)",
"searchOrType": "Search or type custom value...",
@@ -1618,6 +1641,52 @@
"advancedExperimental": "Experimental Features",
"advancedBackgroundTask": "Background Tasks",
"advancedBrowserAutomation": "Browser Automation",
"advancedClaudeCode": "Claude Code"
"advancedClaudeCode": "Claude Code",
"agentDesc": {
"sisyphus": "Main orchestrator",
"hephaestus": "Autonomous deep worker",
"prometheus": "Strategic planner",
"atlas": "Task manager",
"oracle": "Strategic advisor",
"librarian": "Multi-repo researcher",
"explore": "Fast code search",
"multimodalLooker": "Media analyzer",
"metis": "Pre-plan analysis advisor",
"momus": "Plan reviewer",
"sisyphusJunior": "Delegated task executor"
},
"agentTooltip": {
"sisyphus": "Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.",
"atlas": "Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.",
"prometheus": "Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.",
"hephaestus": "Autonomous deep worker (\"legitimate craftsman\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.",
"oracle": "Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.",
"librarian": "Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.",
"explore": "Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.",
"multimodalLooker": "Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.",
"metis": "Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.",
"momus": "Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.",
"sisyphusJunior": "Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops."
},
"categoryDesc": {
"visualEngineering": "Visual/frontend engineering",
"ultrabrain": "Ultra thinking",
"deep": "Deep work",
"artistry": "Creative/artistic",
"quick": "Quick response",
"unspecifiedLow": "General low tier",
"unspecifiedHigh": "General high tier",
"writing": "Writing"
},
"categoryTooltip": {
"visualEngineering": "Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3 Pro model.",
"ultrabrain": "Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.",
"deep": "Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.",
"artistry": "Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3 Pro max variant.",
"quick": "Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.",
"unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.",
"unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.",
"writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model."
}
}
}

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