Compare commits

...

32 Commits

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

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

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

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

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

* Add AWS Bedrock provider implementation plan

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

* Update implementation plan: add OpenCode Bedrock support

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

* feat: add cloud_provider category to ProviderCategory type

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

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

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

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

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

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

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

* docs: add AWS Bedrock provider feature summary for PR

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

* chore: remove internal planning documents

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

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

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

* Add AWS Bedrock UI merge design document

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

* Add AWS Bedrock UI merge implementation plan

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

* feat: skip optional template values in validation

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

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

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

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

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

* feat: extend TemplateValueConfig and merge Bedrock presets

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

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

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

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

* style: replace AWS icon with updated logo

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

* style: replace AWS icon with updated logo

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

* style: replace AWS icon with new PNG image

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

* fix: address code review findings

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

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

* chore: remove planning documents

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

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

Based on maintainer review feedback on PR #1047:

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

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

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

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

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

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

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

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

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

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

---------

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

Replace the provider switching mechanism for Claude/Codex/Gemini from
full settings_config overwrite to partial key-field replacement, preserving
user's non-provider settings (plugins, MCP, permissions, etc.) across switches.

- Add write_live_partial() with per-app implementations for Claude (JSON env
  merge), Codex (auth replace + TOML partial merge), and Gemini (env merge)
- Add backfill_key_fields() to extract only provider-specific fields when
  saving live config back to provider entries
- Update switch_normal, sync_current_to_live, add, update to use partial merge
- Remove common config snippet feature for Claude/Codex/Gemini (no longer
  needed with partial merging); preserve OMO common config
- Delete 6 frontend files (3 components + 3 hooks), clean up 11 modified files
- Remove backend extract_common_config_* methods, 3 Tauri commands,
  CommonConfigSnippets struct, and related migration code
- Update integration tests to validate key-field-only backfill behavior

* refactor(cleanup): remove dead code and redundant MCP sync after partial-merge refactor

- Remove ConfigService legacy full-overwrite sync methods (~150 lines)
- Remove redundant McpService::sync_all_enabled from switch_normal
- Switch proxy fallback recovery from write_live_snapshot to write_live_partial
- Remove dead ProviderService::write_gemini_live wrapper
- Update tests to reflect partial-merge behavior (MCP preserved, not re-synced)

* feat(claude): add Quick Toggles for common Claude Code preferences

Add checkbox toggles for hideAttribution, alwaysThinking, and
enableTeammates that write directly to the live settings file via
RFC 7396 JSON Merge Patch. Mirror changes to the form editor using
form.watch for reactive updates.

* fix(provider): add missing key fields to partial-merge constants

Add provider-specific fields verified against official docs to prevent
key residue or loss during provider switching:

- Claude: CLAUDE_CODE_SUBAGENT_MODEL (env), model (top-level)
- Codex: review_model, plan_mode_reasoning_effort
- Gemini: GOOGLE_API_KEY (official alternative to GEMINI_API_KEY)

* fix(provider): expand partial-merge key fields for Bedrock, Vertex, Foundry and behavior settings

Add missing env/top-level fields to CLAUDE_KEY_ENV_FIELDS and
CLAUDE_KEY_TOP_LEVEL so that provider switching correctly replaces
(and clears) credentials and flags for AWS Bedrock, Google Vertex AI,
Microsoft Foundry, and provider behavior overrides like max output
tokens and prompt caching.

* feat(provider): add auth field selector for Claude providers (AUTH_TOKEN / API_KEY)

Allow users to choose between ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY
when creating or editing custom Claude providers, persisted in meta.apiKeyField.

* refactor(preset): remove AiHubMix hardcoded API_KEY in favor of generic auth selector

AiHubMix was the only preset that hardcoded ANTHROPIC_API_KEY before the
generic auth field selector was introduced. Now that users can freely
choose between AUTH_TOKEN and API_KEY via the UI, remove the special-case
and default AiHubMix to the standard ANTHROPIC_AUTH_TOKEN.
2026-02-23 21:57:04 +08:00
102 changed files with 4189 additions and 5426 deletions
+1
View File
@@ -23,3 +23,4 @@ nul
flatpak/cc-switch.deb
flatpak-build/
flatpak-repo/
.worktrees/
+178 -1
View File
@@ -7,9 +7,186 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
---
## [3.11.0] - 2026-02-26
### Feature Release
This release introduces **OpenClaw** as the fifth supported application, a full **Session Manager** for browsing conversation history across all apps, an independent **Backup Management** panel, **Oh My OpenCode (OMO)** integration, and 50+ other features, fixes, and improvements across 147 commits.
**Stats**: 147 commits | 274 files changed | +32,179 insertions | -5,467 deletions
### Added
#### OpenClaw Support (New Application)
- **OpenClaw Integration**: Full management support for OpenClaw as the fifth application in CC Switch, including provider switching, configuration panels (Env / Tools / Agents Defaults), Workspace file management (HEARTBEAT / BOOTSTRAP / BOOT), daily memory files, and additive overlay mode
- **OpenClaw Provider Presets**: 13+ built-in provider presets with brand icon and complete i18n (zh/en/ja)
- **OpenClaw Form Fields**: Dedicated provider form with providerKey input, model allowlist auto-registration, and default model button
- **OpenClaw Config Panels**: Env editor, Tools editor, and Agents Defaults editor backed by JSON5 read/write (`openclaw_config.rs`)
#### Session Manager
- **Session Manager**: Browse and search conversation history for Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw with table-of-contents navigation and in-session search
- **Session App Filter**: Auto-filter sessions by current app when entering the session page
- **Session Performance**: Parallel directory scanning and head-tail JSONL reading for faster session list loading
#### Backup Management
- **Backup Panel**: Independent backup management panel with configurable backup policy (max count, auto-cleanup) and backup rename support
- **Periodic Backup**: Hourly automatic backup timer during runtime
- **Pre-Migration Backup**: Automatic backup before database schema migrations with backfill warning
- **Delete Backup**: Delete individual backup files with confirmation dialog
- **Backup Time Fix**: Use local time instead of UTC for backup file names
#### Oh My OpenCode (OMO)
- **OMO Integration**: Full Oh My OpenCode config file management with agent model selection, category configuration, and recommended model fill
- **OMO Slim**: Lightweight oh-my-opencode-slim mode support with OmoVariant parameterization
- **OMO Cross-Exclusion**: Enforce OMO ↔ OMO Slim mutual exclusion at the database level
#### Workspace
- **Daily Memory Search**: Full-text search across daily memory files with date-sorted display
- **Clickable Paths**: Directory paths in workspace panels are now clickable; renamed “Today's Note” to “Add Memory”
- **Workspace Files Panel**: Manage bootstrap markdown files for OpenClaw (HEARTBEAT / BOOTSTRAP / BOOT types)
#### Provider Presets
- **AWS Bedrock**: Support for AKSK and API Key authentication modes (Claude and OpenCode)
- **SSAI Code**: Partner provider preset across all five apps
- **CrazyRouter**: Partner provider preset with custom icon
- **AICoding**: Partner provider preset with i18n promotion text
- **Bailian**: Renamed from Qwen Coder with new icon; updated domestic model providers to latest versions
#### Proxy & Network
- **Thinking Budget Rectifier**: New rectifier for thinking budget parameters with dedicated module (`thinking_budget_rectifier.rs`)
- **WebDAV Auto Sync**: Automatic periodic sync with large file protection mechanism
#### UI & UX
- **Theme Animation**: Circular reveal animation when toggling between light and dark themes
- **Claude Quick Toggles**: Quick toggle switches in the Claude config JSON editor for common settings
- **Dynamic Endpoint Hint**: Context-aware hint text in endpoint input based on API format selection
- **AppSwitcher Auto Compact**: Automatically collapse to compact mode based on available width, with smooth transition animation
- **App Transition**: Fade-in/fade-out animation when switching between OpenClaw and other apps
- **Silent Startup Conditional**: Show silent startup option only when launch-on-startup is enabled
#### Settings & Environment
- **First-Run Confirmation**: Confirmation dialogs for proxy and usage features on first use
- **Local Proxy Toggle**: `enableLocalProxy` setting to control proxy UI visibility on the home page
- **Environment Check**: More granular local environment detection (installed CLI tool versions, Volta path detection)
#### Usage & Pricing
- **Usage Dashboard Enhancement**: Auto-refresh control, robust formatting, and request log table improvements
- **New Model Pricing**: Added pricing data for claude-opus-4-6 and gpt-5.3-codex with incremental data seeding
### Changed
#### Architecture
- **Partial Key-Field Merging**: Provider switching now uses partial key-field merging instead of full config overwrite, preserving user's non-provider settings (plugins, MCP, permissions). Removes 6 frontend files and ~150 lines of backend dead code (#1098)
- **Manual Import**: Replaced auto-import on startup with manual “Import Current Config” button in empty state, reducing ~47 lines of startup code
- **OMO Variant Parameterization**: Eliminated ~250 lines of OMO/OMO Slim code duplication via `OmoVariant` struct with STANDARD/SLIM constants
- **OMO Common Config Removal**: Removed the two-layer merge system for OMO common config (-1,733 lines across 21 files)
#### Code Quality
- **ProviderForm Decomposition**: Extracted ProviderForm.tsx from 2,227 lines to 1,526 lines by splitting into 5 focused modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState)
- **Shared MCP/Skills Components**: Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to eliminate duplication across MCP and Skills panels
- **OpenClaw TanStack Query Migration**: Migrated Env, Tools, and AgentsDefaults panels from manual useState/useEffect to centralized TanStack Query hooks
#### Settings Layout
- **Proxy Tab**: Split Advanced tab into dedicated Proxy tab (local proxy, failover, rectifiers, global outbound proxy); moved pricing config to Usage dashboard as collapsible accordion. SettingsPage reduced from ~716 to ~426 lines with 5-tab layout: General | Proxy | Advanced | Usage | About
- **Data Section Split**: Split data accordion into Import/Export and Cloud Sync sections for better discoverability
#### Terminal & Config
- **Unified Terminal Selection**: Consolidated terminal preference to global settings; added WezTerm support and terminal name mapping (iterm2 → iterm)
- **OpenClaw Agents Panel**: Primary model field set to read-only; detailed model fields (context window, max tokens, reasoning, cost) moved to advanced options
- **Claude Model Update**: Updated Claude model references from 4.5 to 4.6 across all provider presets
### Fixed
- **Windows Home Dir Regression**: Prevent providers/settings “disappearing” after upgrading from v3.10.2 → v3.10.3 when `HOME` differs from the real user profile directory; restore default path resolution and auto-detect the v3.10.3 legacy database location.
#### Critical
- **Windows Home Dir Regression**: Restored default home directory resolution on Windows to prevent providers/settings “disappearing” when `HOME` env var differs from the real user profile directory (Git/MSYS environments); auto-detects v3.10.3 legacy database location
- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent EGL initialization failure causing blank screen on startup
- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to OpenAI Chat Completions endpoints, fixing request failures for Nvidia and other `apiFormat=”openai_chat”` providers
- **Health Check Auth Mode**: Health check now respects provider's auth_mode setting instead of always using x-api-key header
#### Provider & Preset
- **OpenClaw /v1 Prefix**: Removed /v1 prefix from OpenClaw anthropic-messages presets to prevent double path (/v1/v1/messages) with Anthropic SDK auto-append
- **Opus Pricing**: Corrected Opus pricing from $15/$75 to $5/$25 and upgraded model ID to claude-opus-4-6
- **AIGoCode URLs**: Unified API base URL to https://api.aigocode.com across all apps; removed trailing /v1 suffix
- **Zhipu GLM**: Removed outdated partner status from Claude, OpenCode, and OpenClaw presets
- **API Key Visibility**: Restored API Key input field when creating new Claude providers (was incorrectly hidden for non-cloud_provider categories)
#### OMO / OMO Slim
- **OMO Slim Category Checks**: Added missing omo-slim category checks across add/form/mutation paths
- **OMO Slim Cache Invalidation**: Invalidate OMO Slim query cache after provider mutations to prevent stale UI state
- **OMO Recommended Models**: Synced agent/category recommended models with upstream sources; fixed provider/model format to pure model IDs
- **OMO Fill Feedback**: Added toast feedback when “Fill Recommended” button silently fails
- **OMO Last-Provider Restriction**: Removed last-provider deletion restriction for OMO/OMO Slim plugins
- **OpenCode Model Validation**: Reject saving OpenCode providers without at least one configured model
#### OpenClaw
- **OpenClaw P0-P3 Fixes**: Fixed 25 missing i18n keys, replaced key={index} with stable crypto.randomUUID(), excluded openclaw from ProxyToggle/FailoverToggle, added deep link merge_additive_config(), unified serde(flatten) naming, added directory existence checks, removed dead code, added duplicate key validation
- **OpenClaw Robustness**: Fixed EnvPanel visibleKeys using entry key names instead of array indices; added NaN guards; validated provider ID and model before import
- **OpenClaw i18n Dedup**: Merged duplicate openclaw i18n keys to restore provider form translations
#### Platform
- **Window Flash**: Prevented window flicker on silent startup (Windows)
- **Title Bar Theme**: Title bar now follows dark/light mode theme changes
- **Skills Path Separator**: Fixed path separator matching for skill installation status on Windows (supports both `/` and `\`)
- **WSL Conditional Compilation**: Added `#[cfg(target_os = “windows”)]` to WSL helper functions to eliminate dead_code warnings on non-Windows platforms
#### UI
- **Toolbar Clipping**: Removed toolbar height limit that was clipping AppSwitcher
- **Update Badge**: Show update badge instead of green check when a newer version is available
- **Session Button Visibility**: Only show Session Manager button for Claude and Codex apps
- **Directory Spacing**: Added vertical spacing between directory setting sections
- **Dark Mode Cards**: Unified SQL import/export card styling in dark mode
- **OpenClaw Scroll**: Enabled scrolling for OpenClaw configuration panel content
#### i18n & Localization
- **Session Manager i18n**: Replaced hardcoded Chinese strings with i18n keys for relative time, role labels, and UI elements
- **OpenClaw Default Model Label**: Renamed “Enable/Default” to “Set as Default / Current Default” with wider button
- **Daily Memory Sort**: Sort daily memory files by filename date (YYYY-MM-DD.md) instead of modification time
- **Backup Name i18n**: Use local time for backup file names
#### Other
- **Skill Doc URL**: Use actual branch from download_repo for documentation URL; switched from /tree/ to /blob/ pointing to SKILL.md
- **OpenCode Install Detection**: Added install.sh priority paths (OPENCODE_INSTALL_DIR > XDG_BIN_DIR > ~/bin > ~/.opencode/bin) with path dedup and cross-platform executable candidates
- **Provider Auto-Import**: Removed auto-import side effect from useProvidersQuery queryFn; users now trigger import manually via empty state button
- **Manual Backup Validation**: Treat missing database file as error during manual backup to prevent false success toast
### Performance
- **Session Panel Loading**: Parallel directory scanning and head-tail JSONL reading for Codex, OpenClaw, and OpenCode session providers
- **Query Cache Cleanup**: Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls
### Documentation
- **Sponsors**: Added/updated SSSAiCode, Crazyrouter, AICoding, Right Code, and MiniMax sponsor entries across all README languages
- **User Manual**: Added user manual documentation (#979)
### Maintenance
- **Pre-Release Cleanup**: Removed debug logs, fixed clippy warnings, added missing Japanese translations, and formatted code
- **UI Exclusions**: Hidden MCP, Skills, proxy/pricing, stream check, and model test panels for OpenClaw where not applicable
---
+1
View File
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
**Core Capabilities**
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **AWS Bedrock Support**: Built-in AWS Bedrock provider presets with AKSK and API Key authentication, cross-region inference support (global/us/eu/apac), covering Claude Code and OpenCode
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
+1
View File
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
**コア機能**
- **プロバイダ管理**Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
- **AWS Bedrock 対応**AWS Bedrock プロバイダプリセットを内蔵、AKSK および API Key 認証に対応、クロスリージョン推論(global/us/eu/apac)をサポート、Claude Code と OpenCode に対応
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
+1
View File
@@ -157,6 +157,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **AWS Bedrock 支持**:内置 AWS Bedrock 供应商预设,支持 AKSK 和 API Key 两种认证方式,支持跨区域推理(global/us/eu/apac),覆盖 Claude Code 和 OpenCode
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
+291
View File
@@ -0,0 +1,291 @@
# CC Switch v3.11.0
> OpenClaw Support, Session Manager, Backup Management & 50+ Improvements
**[中文版 →](release-note-v3.11.0-zh.md) | [日本語版 →](release-note-v3.11.0-ja.md)**
---
## Overview
CC Switch v3.11.0 is a major update that adds full management support for **OpenClaw** as the fifth application, introduces a new **Session Manager** and **Backup Management** feature. Additionally, **Oh My OpenCode (OMO) integration**, the **partial key-field merging** architecture upgrade for provider switching, **settings page refactoring**, and many other improvements make the overall experience more polished.
**Release Date**: 2026-02-26
**Update Scale**: 147 commits | 274 files changed | +32,179 / -5,467 lines
---
## Highlights
- **OpenClaw Support**: Fifth managed application with 13 provider presets, Env/Tools/AgentsDefaults config editors, and Workspace file management
- **Session Manager**: Browse conversation history across all five apps with table-of-contents navigation and in-session search
- **Backup Management**: Independent backup panel with configurable policies, periodic backups, and pre-migration auto-backup
- **Oh My OpenCode Integration**: Full OMO config management with OMO Slim lightweight mode support
- **Partial Key-Field Merging**: Provider switching now preserves user's non-provider settings
- **Settings Page Refactoring**: 5-tab layout with ~40% code reduction
- **6 New Provider Presets**: AWS Bedrock, SSAI Code, CrazyRouter, AICoding, and more
- **Thinking Budget Rectifier**: Fine-grained thinking budget control
- **Theme Switch Animation**: Circular reveal transition animation
- **WebDAV Auto Sync**: Automatic sync with large file protection
---
## Main Features
### OpenClaw Support (New Fifth App)
Full management support for OpenClaw, the fifth managed application following Claude Code, Codex, Gemini CLI, and OpenCode.
- **Provider Management**: Add, edit, switch, and delete OpenClaw providers with 13 built-in presets
- **Config Editors**: Three dedicated panels for Env (environment variables), Tools, and AgentsDefaults
- **Workspace Panel**: HEARTBEAT/BOOTSTRAP/BOOT file management and daily memory
- **Additive Overlay Mode**: Support config overlay instead of overwrite
- **Default Model Button**: One-click to fill recommended models; auto-register suggested models to allowlist when adding providers
- **Brand & Interaction**: Dedicated brand icon, fade-in/fade-out transition animation when switching apps
- **Deep Link Support**: Import OpenClaw provider configurations via URL
- **Full Internationalization**: Complete Chinese/English/Japanese support
### Session Manager
A brand-new session manager to browse and search conversation history.
- Browse conversation history across Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw (#867, thanks @TinsFox)
- Table-of-contents navigation and in-session search
- Auto-filter by current app when entering the session page
- Parallel directory scanning + head-tail JSONL reading for optimized loading performance
### Backup Management
An independent backup management panel for better data safety.
- Configurable backup policy: maximum backup count and auto-cleanup rules
- Hourly automatic backup timer during runtime
- Auto-backup before database schema migrations with backfill warning
- Support backup rename and deletion (with confirmation dialog)
- Backup filenames use local time for better clarity
### Oh My OpenCode (OMO) Integration
Full Oh My OpenCode config file management.
- Agent model selection, category configuration, and recommended model fill (#972, thanks @yovinchen)
- Improved agent model selection UX with lowercase key fix (#1004, thanks @yovinchen)
- OMO Slim lightweight mode support
- OMO ↔ OMO Slim mutual exclusion (enforced at database level)
### Workspace
- Full-text search across daily memory files, sorted by date
- Clickable directory paths for quick file location access
### Toolbar
- AppSwitcher auto-collapses to compact mode based on available width
- Smooth transition animation for compact mode toggle
### Settings
- First-use confirmation dialogs for proxy and usage features to prevent accidental operations
- New `enableLocalProxy` switch to control proxy UI visibility on home page
- More granular local environment checks: CLI tool version detection (#870, thanks @kv-chiu), Volta path detection (#969, thanks @myjustify)
### Provider Presets
- **AWS Bedrock**: Support for AKSK and API Key authentication modes (#1047, thanks @keithyt06)
- **SSAI Code**: Partner preset across all five apps
- **CrazyRouter**: Partner preset with dedicated icon
- **AICoding**: Partner preset with i18n promotion text
- Updated domestic model provider presets to latest versions
- Renamed Qwen Coder to Bailian (#965, thanks @zhu-jl18)
### Other New Features
- **Thinking Budget Rectifier**: Fine-grained thinking budget allocation control (#1005, thanks @yovinchen)
- **WebDAV Auto Sync**: Automatic sync with large file protection (#923, thanks @clx20000410; #1043, thanks @SaladDay)
- **Theme Switch Animation**: Circular reveal transition for a smoother visual experience (#905, thanks @funnytime75)
- **Claude Config Editor Quick Toggles**: Quick toggle switches for common settings (#1012, thanks @JIA-ss)
- **Dynamic Endpoint Hint**: Context-aware hint text based on API format selection (#860, thanks @zhu-jl18)
- **Usage Dashboard Enhancement**: Auto-refresh control and robust formatting (#942, thanks @yovinchen)
- **New Pricing Data**: claude-opus-4-6 and gpt-5.3-codex (#943, thanks @yovinchen)
- **Silent Startup Optimization**: Silent startup option only shown when launch-on-startup is enabled
---
## Architecture Improvements
### Partial Key-Field Merging (Important Change)
Provider switching now uses partial key-field merging instead of full config overwrite (#1098). When switching providers, only provider-related key-values are updated, preserving user's non-provider settings (plugins, MCP, permissions, etc.). This refactoring removed 6 frontend files and ~150 lines of backend dead code.
### Manual Import Replaces Auto-Import
Startup no longer auto-imports external configurations. Users now click "Import Current Config" manually, preventing accidental data overwrites.
### OmoVariant Parameterization
Eliminated ~250 lines of duplicated code in the OMO module via `OmoVariant` struct parameterization.
### OMO Common Config Removal
Removed the two-layer merge system, reducing ~1,733 lines of code and simplifying the architecture.
### ProviderForm Decomposition
Reduced ProviderForm component from 2,227 lines to 1,526 lines by extracting 5 independent modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState), significantly improving maintainability.
### Shared MCP/Skills Components
Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to reduce duplication across MCP and Skills panels (#897, thanks @PeanutSplash).
### Settings Page Refactoring
Refactored settings page to a 5-tab layout (General | Proxy | Advanced | Usage | About), reducing SettingsPage code from ~716 to ~426 lines.
### Other Improvements
- Unified terminal selection via global settings with WezTerm support added
- Updated Claude model references from 4.5 to 4.6
---
## Bug Fixes
### Critical Fixes
- **Windows Home Dir Regression**: Restored default home directory resolution to prevent providers/settings "disappearing" when `HOME` env var differs from the real user profile directory in Git/MSYS environments
- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent blank screen on startup (#986, thanks @ThendCN)
- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to `/v1/chat/completions` endpoints, fixing request failures for Nvidia and other `apiFormat="openai_chat"` providers (#1052, thanks @jnorthrup)
- **Health Check Auth**: Health check now respects provider's `auth_mode` setting, preventing failures for proxy services that only support Bearer authentication (#824, thanks @Jassy930)
### Provider Preset Fixes
- Fixed OpenClaw `/v1` prefix causing double path (/v1/v1/messages)
- Corrected Opus pricing ($15/$75 → $5/$25) and upgraded to 4.6
- Unified AIGoCode URL to `https://api.aigocode.com` across all apps
- Removed outdated partner status from Zhipu GLM presets
- Restored API Key input visibility when creating new Claude providers
- Hide quick toggles for non-active providers, show context-aware JSON editor hints
### OMO Fixes
- Added missing omo-slim category checks across add/form/mutation paths
- Fixed OMO Slim query cache invalidation after provider mutations
- Synced OMO agent/category recommended models with upstream sources
- Added toast feedback for "Fill Recommended" button silent failures
- Removed last-provider deletion restriction for OMO/OMO Slim
- Reject saving OpenCode providers without configured models (#932, thanks @yovinchen)
### OpenClaw Fixes
- Fixed 25 missing i18n keys, replaced key={index} with stable IDs, added deep link additive merge, and other code review issues
- Enhanced EnvPanel robustness (NaN guards, entry key names instead of array indices)
- Merged duplicate i18n keys to restore provider form translations
### Platform Fixes
- Windows silent startup window flicker (#901, thanks @funnytime75)
- Title bar dark mode theme following (#903, thanks @funnytime75)
- Windows Skills path separator matching (#868, thanks @stmoonar)
- WSL helper functions conditional compilation
### UI Fixes
- Toolbar height clipping causing AppSwitcher to be obscured
- Show update badge instead of green checkmark when newer version available
- Session Manager button only visible for Claude/Codex apps
- Unified SQL import/export card dark mode styling (#1067, thanks @SaladDay)
### Other Fixes
- Replaced hardcoded Chinese strings in Session Manager with i18n keys
- Fixed Skill documentation URL branch and path resolution (#977, thanks @yovinchen)
- Added missing OpenCode install.sh installation path detection (#988, thanks @zhu-jl18)
- Fixed Skill ZIP symlink resolution (#1040, thanks @yovinchen)
- Added missing OpenCode checkbox in MCP add/edit form (#1026, thanks @yovinchen)
- Removed auto-import side effect from useProvidersQuery queryFn
---
## Performance
- Parallel directory scanning + head-tail JSONL reading for session panel, significantly improving session list loading speed
- Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls
---
## Documentation
- Sponsor updates: SSSAiCode, Crazyrouter, AICoding, Right Code, MiniMax
- Added user manual documentation (#979, thanks @yovinchen)
---
## Notes & Considerations
- **OpenClaw is a newly supported app**: OpenClaw CLI must be installed first to use related features.
- **Partial key-field merging is an important architecture change**: Provider switching no longer overwrites the entire config file, but only merges provider-related key-values. Please note this change if you previously relied on full overwrite behavior.
- **Auto-import changed to manual**: External configurations are no longer auto-imported on startup. Click "Import Current Config" manually when needed.
- **OMO and OMO Slim are mutually exclusive**: Only one can be active at a time. Switching to one automatically disables the other.
- **Backup is enabled by default**: Automatic hourly backup during runtime. Adjust the policy in the Backup panel.
---
## Special Thanks
Thanks to all contributors for their contributions to this release!
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ---------------------------------------- | ---------------------------------------------------- |
| `CC-Switch-v3.11.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.11.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| -------------------------------- | -------------------------------------------------------------------- |
| `CC-Switch-v3.11.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.11.0-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+291
View File
@@ -0,0 +1,291 @@
# CC Switch v3.11.0
> OpenClaw サポート、セッションマネージャー、バックアップ管理と 50 以上の改善
**[中文版 →](release-note-v3.11.0-zh.md) | [English →](release-note-v3.11.0-en.md)**
---
## 概要
CC Switch v3.11.0 は大規模なアップデートです。5番目のアプリケーション **OpenClaw** の完全管理サポートを追加し、新しい**セッションマネージャー**と**バックアップ管理**機能を導入しました。さらに、**Oh My OpenCode (OMO) 統合**、プロバイダー切り替えの**部分キーフィールドマージ**アーキテクチャアップグレード、**設定ページのリファクタリング**など、多数の改善により全体的な体験がさらに向上しました。
**リリース日**: 2026-02-26
**更新規模**: 147 commits | 274 files changed | +32,179 / -5,467 lines
---
## ハイライト
- **OpenClaw サポート**: 5番目の管理対象アプリ、13 のプロバイダープリセット、Env/Tools/AgentsDefaults 設定エディター、Workspace ファイル管理
- **セッションマネージャー**: 5つのアプリの会話履歴を閲覧、目次ナビゲーションとセッション内検索
- **バックアップ管理**: 独立バックアップパネル、設定可能なポリシー、定期バックアップ、マイグレーション前自動バックアップ
- **Oh My OpenCode 統合**: 完全な OMO 設定管理、OMO Slim 軽量モードサポート
- **部分キーフィールドマージ**: プロバイダー切り替え時にユーザーの非プロバイダー設定を保持
- **設定ページリファクタリング**: 5タブレイアウト、コード量約 40% 削減
- **6つの新プロバイダープリセット**: AWS Bedrock、SSAI Code、CrazyRouter、AICoding など
- **Thinking Budget Rectifier**: より精密な thinking budget 制御
- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション
- **WebDAV 自動同期**: 自動同期と大容量ファイル保護
---
## 主な機能
### OpenClaw サポート(新しい5番目のアプリ)
Claude Code、Codex、Gemini CLI、OpenCode に続く5番目の管理対象アプリケーションとして OpenClaw の完全管理サポートを追加しました。
- **プロバイダー管理**: OpenClaw プロバイダーの追加、編集、切り替え、削除、13 の内蔵プリセット
- **設定エディター**: Env(環境変数)、Tools(ツール)、AgentsDefaults(エージェントデフォルト)の3つの専用パネル
- **Workspace パネル**: HEARTBEAT/BOOTSTRAP/BOOT ファイル管理とデイリーメモリ
- **Additive オーバーレイモード**: 上書きではなく設定の重ね合わせをサポート
- **デフォルトモデルボタン**: ワンクリックで推奨モデルを入力、プロバイダー追加時に候補モデルを allowlist に自動登録
- **ブランドとインタラクション**: 専用ブランドアイコン、アプリ切り替えフェード遷移アニメーション
- **ディープリンクサポート**: URL 経由で OpenClaw プロバイダー設定をインポート
- **完全な国際化**: 中/英/日 三言語完全対応
### セッションマネージャー
会話履歴を閲覧・検索できる新しいセッションマネージャーです。
- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw の5つのアプリの会話履歴を閲覧(#867@TinsFox に感謝)
- 目次ナビゲーションとセッション内検索
- セッションページに入ると現在のアプリで自動フィルター
- 並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで読み込みパフォーマンスを最適化
### バックアップ管理
データの安全性を高める独立バックアップ管理パネルです。
- 設定可能なバックアップポリシー: 最大バックアップ数、自動クリーンアップルール
- ランタイム中の1時間ごとの定期自動バックアップ
- データベースマイグレーション前の自動バックアップ、バックフィル警告プロンプト
- バックアップのリネームと削除をサポート(確認ダイアログ付き)
- バックアップファイル名にローカルタイムを使用、より直感的に
### Oh My OpenCode (OMO) 統合
完全な Oh My OpenCode 設定ファイル管理です。
- エージェントモデル選択、カテゴリ設定、推奨モデル入力(#972@yovinchen に感謝)
- エージェントモデル選択 UX の改善、lowercase key 問題の修正(#1004@yovinchen に感謝)
- OMO Slim 軽量モードサポート
- OMO と OMO Slim の相互排他(データベースレベルで一貫性を保証)
### ワークスペース
- デイリーメモリファイルの全文検索、日付順ソート
- ディレクトリパスがクリック可能に、ファイル位置をすばやく開く
### ツールバー
- AppSwitcher がウィンドウ幅に応じて自動的にコンパクトモードに折りたたみ
- コンパクトモード切り替えのスムーズ遷移アニメーション
### 設定
- プロキシと使用量機能に初回使用確認ダイアログを追加、誤操作を防止
- `enableLocalProxy` スイッチを追加、ホーム画面のプロキシ UI 表示を制御
- より詳細なローカル環境チェック: CLI ツールバージョン検出(#870@kv-chiu に感謝)、Volta パス検出(#969@myjustify に感謝)
### プロバイダープリセット
- **AWS Bedrock**: AKSK と API Key の2種類の認証方式をサポート(#1047@keithyt06 に感謝)
- **SSAI Code**: パートナープリセット、5アプリ対応
- **CrazyRouter**: パートナープリセットと専用アイコン
- **AICoding**: パートナープリセットとプロモーションテキスト
- 国内モデルプロバイダープリセットを最新版に更新
- Qwen Coder を百炼 (Bailian) にリネーム(#965@zhu-jl18 に感謝)
### その他の新機能
- **Thinking Budget Rectifier**: より精密な thinking budget 制御(#1005@yovinchen に感謝)
- **WebDAV 自動同期**: 自動同期設定と大容量ファイル保護(#923@clx20000410 に感謝;#1043@SaladDay に感謝)
- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション(#905@funnytime75 に感謝)
- **Claude 設定エディタークイックトグル**: よく使う設定項目のクイック切り替え(#1012@JIA-ss に感謝)
- **動的エンドポイントヒント**: API フォーマット選択に基づく動的ヒントテキスト(#860@zhu-jl18 に感謝)
- **使用量ダッシュボード強化**: 自動更新、堅牢なフォーマット(#942@yovinchen に感謝)
- **新しい価格データ**: claude-opus-4-6 と gpt-5.3-codex#943@yovinchen に感謝)
- **サイレント起動の最適化**: サイレント起動オプションは自動起動が有効な場合のみ表示
---
## アーキテクチャ改善
### 部分キーフィールドマージ(重要な変更)
プロバイダー切り替えを完全な設定上書きから部分キーフィールドマージ戦略に変更しました(#1098)。プロバイダー切り替え時に、プロバイダー関連のキー値のみを更新し、ユーザーの非プロバイダー設定(プラグイン設定、MCP 設定、権限設定など)を保持します。このリファクタリングにより、フロントエンドファイル6つと約150行のバックエンドデッドコードを削除しました。
### 手動インポートに変更
起動時の自動インポートを廃止し、手動の「現在の設定をインポート」ボタンに変更。意図しないユーザーデータの上書きを防止します。
### OmoVariant パラメータ化
`OmoVariant` 構造体によるパラメータ化で、OMO モジュールの約250行の重複コードを削除しました。
### OMO 共通設定の削除
2層マージシステムを削除し、約1,733行のコードを削減、アーキテクチャを簡素化しました。
### ProviderForm 分割
ProviderForm コンポーネントを2,227行から1,526行に削減し、5つの独立モジュール(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState)に分離。保守性が大幅に向上しました。
### MCP/Skills 共有コンポーネント
AppCountBar、AppToggleGroup、ListItemRow などの共有コンポーネントを抽出し、MCP と Skills パネルの重複コードを削減(#897@PeanutSplash に感謝)。
### 設定ページリファクタリング
設定ページを5タブレイアウト(一般 | プロキシ | 詳細 | 使用量 | 情報)にリファクタリング。SettingsPage のコードを約716行から約426行に削減しました。
### その他の改善
- ターミナル統一: グローバル設定でターミナル選択を統一、WezTerm サポートを追加
- Claude モデル参照を 4.5 から 4.6 に更新
---
## バグ修正
### 重大な修正
- **Windows ホームディレクトリ回帰**: デフォルトのホームディレクトリ解決を復元し、Git/MSYS 環境でのデータベースパス変更によるデータ「消失」を防止
- **Linux 白画面**: AMD GPU の WebKitGTK ハードウェアアクセラレーションを無効化し、一部の Linux システムの起動白画面問題を解決(#986@ThendCN に感謝)
- **OpenAI Beta パラメータ**: `/v1/chat/completions``?beta=true` を追加しないように修正、Nvidia など OpenAI Chat 形式を使用するプロバイダーのリクエスト失敗を修正(#1052@jnorthrup に感謝)
- **ヘルスチェック認証**: プロバイダーの `auth_mode` 設定を尊重し、Bearer 認証のみをサポートするプロキシサービスのヘルスチェック失敗を回避(#824@Jassy930 に感謝)
### プロバイダープリセット修正
- OpenClaw `/v1` プレフィックスの二重パス問題を修正
- Opus 価格修正($15/$75 → $5/$25)と 4.6 へのアップグレード
- AIGoCode URL を `https://api.aigocode.com` に統一
- Zhipu GLM の古いパートナーステータスを削除
- 新規 Claude プロバイダー作成時の API Key 入力フィールドの表示を復元
- 非アクティブプロバイダーのクイックトグルを非表示、コンテキスト対応の JSON エディターヒントを表示
### OMO 修正
- omo-slim カテゴリチェックの補完(add/form/mutation パス)
- OMO Slim プロバイダー変更後のクエリキャッシュ無効化を修正
- OMO agent/category 推奨モデルをアップストリームソースと同期
- 「推奨を入力」ボタン失敗時の toast フィードバックを追加
- OMO/OMO Slim の最後のプロバイダー削除制限を撤廃
- OpenCode でモデル未設定時の保存を拒否(#932@yovinchen に感謝)
### OpenClaw 修正
- 25個の欠落 i18n キー、key={index} を安定 ID に置換、ディープリンク additive マージなどのコードレビュー問題を修正
- EnvPanel 堅牢性強化(NaN ガード、配列インデックスではなくエントリーキー名を使用)
- i18n 重複キーのマージ、プロバイダーフォーム翻訳を復元
### プラットフォーム修正
- Windows サイレント起動時のウィンドウフラッシュ(#901@funnytime75 に感謝)
- タイトルバーのダークモード追従(#903@funnytime75 に感謝)
- Windows の Skills パスセパレーターマッチング(#868@stmoonar に感謝)
- WSL ヘルパー関数の条件付きコンパイル
### UI 修正
- ツールバーの高さクリッピングによる AppSwitcher の遮蔽を修正
- 新バージョンがある場合、緑のチェックマークではなく更新バッジを表示
- セッションマネージャーボタンを Claude/Codex アプリでのみ表示
- SQL インポート/エクスポートカードのダークモードスタイルを統一(#1067@SaladDay に感謝)
### その他の修正
- セッションマネージャーのハードコードされた中国語文字列を i18n キーに置換
- Skill ドキュメント URL のブランチとパスを修正(#977@yovinchen に感謝)
- OpenCode install.sh インストールパス検出の補完(#988@zhu-jl18 に感謝)
- Skill ZIP シンボリックリンク解決の修正(#1040@yovinchen に感謝)
- MCP フォームに OpenCode チェックボックスを追加(#1026@yovinchen に感謝)
- useProvidersQuery の自動インポート副作用を削除
---
## パフォーマンス最適化
- セッションパネルの並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで、セッションリスト読み込み速度を大幅向上
- Tauri ローカル IPC の不要な query cache を削除し、メモリ使用量を削減
---
## ドキュメント
- スポンサー更新: SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax
- ユーザーマニュアルを追加(#979@yovinchen に感謝)
---
## 注意事項
- **OpenClaw は新しくサポートされたアプリです**: 関連機能を使用するには、先に OpenClaw CLI をインストールする必要があります。
- **部分キーフィールドマージは重要なアーキテクチャ変更です**: プロバイダー切り替え時に設定ファイルを完全上書きするのではなく、プロバイダー関連のキー値のみをマージするようになりました。以前の完全上書き動作に依存していた場合はご注意ください。
- **自動インポートは手動に変更されました**: 起動時に外部設定を自動インポートしなくなりました。必要に応じて「現在の設定をインポート」を手動でクリックしてください。
- **OMO と OMO Slim は相互排他**: 同時に一つだけ有効にできます。切り替え時にもう一方は自動的に無効になります。
- **バックアップ機能はデフォルトで有効**: ランタイム中に1時間ごとに自動バックアップします。バックアップパネルでポリシーを調整できます。
---
## 特別な感謝
以下のコントリビューターの皆様、このリリースへの貢献に感謝します!
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | ---------------------------------------------------- |
| `CC-Switch-v3.11.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.11.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| -------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.11.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.11.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+291
View File
@@ -0,0 +1,291 @@
# CC Switch v3.11.0
> OpenClaw 支持、会话管理器、备份管理与 50+ 项改进
**[English →](release-note-v3.11.0-en.md) | [日本語版 →](release-note-v3.11.0-ja.md)**
---
## 概览
CC Switch v3.11.0 是一次大规模更新,新增第五个应用 **OpenClaw** 的完整管理支持,同时带来全新的**会话管理器**和**备份管理**功能。此外,**Oh My OpenCode (OMO) 集成**、供应商切换的**部分键值合并**架构升级、**设置页面重构**等多项改进使整体体验更加完善。
**发布日期**2026-02-26
**更新规模**147 commits | 274 files changed | +32,179 / -5,467 lines
---
## 重点内容
- **OpenClaw 支持**:第五个受管理应用,含 13 个供应商预设、Env/Tools/AgentsDefaults 配置编辑器、Workspace 文件管理
- **会话管理器**:浏览五个应用的历史会话,支持目录导航和会话内搜索
- **备份管理**:独立备份面板,可配置策略、定时备份、迁移前自动备份
- **Oh My OpenCode 集成**:完整 OMO 配置管理,支持 OMO Slim 轻量模式
- **部分键值合并**:供应商切换不再全量覆写,保留用户的非供应商设置
- **设置页面重构**:5 标签页布局,代码量减少约 40%
- **6 组新供应商预设**AWS Bedrock、SSAI Code、CrazyRouter、AICoding 等
- **Thinking Budget Rectifier**:代理矫正器,更精细的 thinking budget 控制
- **主题切换动画**:圆形揭示过渡动画,视觉体验升级
- **WebDAV 自动同步**:支持自动同步与大文件防护
---
## 主要功能
### OpenClaw 支持(新增第五应用)
CC Switch 新增对 OpenClaw 的完整管理支持,这是继 Claude Code、Codex、Gemini CLI、OpenCode 之后的第五个受管理应用。
- **供应商管理**:新增、编辑、切换、删除 OpenClaw 供应商,含 13 个内置预设
- **配置编辑器**:Env(环境变量)、Tools(工具)、AgentsDefaults(代理默认值)三个专属配置面板
- **Workspace 面板**:支持 HEARTBEAT/BOOTSTRAP/BOOT 文件管理及每日记忆
- **Additive 叠加模式**:支持配置叠加而非覆盖
- **默认模型按钮**:一键填充推荐模型,添加供应商时自动将建议模型注册到 allowlist
- **品牌与交互**:专属品牌图标、应用切换淡入淡出过渡动画
- **深链接支持**:通过 URL 导入 OpenClaw 供应商配置
- **完整国际化**:中/英/日三语全面支持
### 会话管理器 Sessions
全新的会话管理器,帮助你浏览和检索历史会话记录。
- 支持浏览 Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw 五个应用的历史会话(#867,感谢 @TinsFox
- 目录导航和会话内搜索
- 进入会话页面时默认过滤为当前应用,快速定位
- 并行目录扫描 + 头尾 JSONL 读取,优化加载性能
### 备份管理 Backup
独立的备份管理面板,让数据安全更有保障。
- 可配置备份策略:最大备份数量、自动清理规则
- 运行时每小时定期自动备份
- 数据库迁移前自动备份,带回填警告提示
- 支持备份重命名和删除(含确认对话框)
- 备份文件名使用本地时间,更直观
### Oh My OpenCode (OMO) 集成
完整的 Oh My OpenCode 配置文件管理。
- Agent 模型选择、Category 配置、推荐模型填充(#972,感谢 @yovinchen
- 改进 Agent 模型选择 UX,修复 lowercase key 问题(#1004,感谢 @yovinchen
- OMO Slim 轻量模式支持
- OMO 与 OMO Slim 互斥切换(数据库层级强制保证一致性)
### 工作空间 Workspace
- 每日记忆文件全文搜索,按日期排序
- 目录路径可点击跳转,快速打开文件位置
### 工具栏 Toolbar
- AppSwitcher 根据窗口宽度自动折叠为紧凑模式
- 紧凑模式切换平滑过渡动画
### 设置 Settings
- 代理和用量功能新增首次使用确认对话框,避免误操作
- 新增 `enableLocalProxy` 开关,控制主页代理 UI 显示
- 更精细的本地环境检查:CLI 工具版本检测(#870,感谢 @kv-chiu)、Volta 路径检测(#969,感谢 @myjustify
### 供应商预设 Preset
- **AWS Bedrock**:支持 AKSK 和 API Key 两种认证方式(#1047,感谢 @keithyt06
- **SSAI Code**:合作伙伴预设,覆盖五端
- **CrazyRouter**:合作伙伴预设及专属图标
- **AICoding**:合作伙伴预设及推广文案
- 更新国内模型供应商预设至最新版本
- Qwen Coder 重命名为百炼 (Bailian)#965,感谢 @zhu-jl18
### 其他新功能
- **Thinking Budget Rectifier**:代理矫正器,更精细地控制 thinking budget 分配(#1005,感谢 @yovinchen
- **WebDAV 自动同步**:支持自动同步配置,并增加大文件防护(#923,感谢 @clx20000410#1043,感谢 @SaladDay
- **主题切换动画**:圆形揭示过渡动画,视觉体验更流畅(#905,感谢 @funnytime75
- **Claude 配置编辑器快速开关**:快速切换常用配置项(#1012,感谢 @JIA-ss
- **动态端点提示**:根据 API 格式选择动态显示端点提示文本(#860,感谢 @zhu-jl18
- **用量仪表盘增强**:自动刷新、更强健的数据格式化(#942,感谢 @yovinchen
- **新增定价数据**claude-opus-4-6 和 gpt-5.3-codex#943,感谢 @yovinchen
- **静默启动优化**:静默启动选项仅在开机启动开启时显示
---
## 架构改进
### 部分键值合并(重要变更)
供应商切换从全量配置覆写改为部分键值合并策略(#1098)。切换供应商时,仅更新供应商相关的键值,保留用户的非供应商设置(如插件配置、MCP 配置、权限设置等)。此次重构删除了 6 个前端文件、约 150 行后端死代码。
### 手动导入替代自动导入
启动时不再自动导入外部配置,改为手动点击"导入当前配置"按钮,避免意外覆盖用户数据。
### OMO Variant 参数化
通过 `OmoVariant` 结构体参数化消除 OMO 模块约 250 行重复代码。
### OMO 公共配置移除
删除二层合并系统,减少约 1,733 行代码,简化架构。
### ProviderForm 拆分
ProviderForm 组件从 2,227 行减至 1,526 行,提取 5 个独立模块(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState),可维护性显著提升。
### MCP/Skills 共享组件
提取 AppCountBar、AppToggleGroup、ListItemRow 等共享组件,减少 MCP 和 Skills 面板的重复代码(#897,感谢 @PeanutSplash)。
### 设置页面重构
设置页面重构为 5 标签页布局(通用 | 代理 | 高级 | 用量 | 关于),SettingsPage 代码从约 716 行减至约 426 行。
### 其他改进
- 终端统一:全局设置统一终端选择,新增 WezTerm 支持
- Claude 模型引用从 4.5 更新到 4.6
---
## Bug 修复
### 严重修复
- **Windows 主目录回归**:恢复默认主目录解析,防止 Git/MSYS 环境下数据库路径变更导致数据"丢失"
- **Linux 白屏**:禁用 AMD GPU 的 WebKitGTK 硬件加速,解决部分 Linux 系统启动白屏问题(#986,感谢 @ThendCN
- **OpenAI Beta 参数**:不再为 `/v1/chat/completions` 添加 `?beta=true`,修复 Nvidia 等使用 OpenAI Chat 格式的供应商请求失败(#1052,感谢 @jnorthrup
- **健康检查认证**:尊重供应商 `auth_mode` 设置,避免仅支持 Bearer 认证的代理服务健康检查失败(#824,感谢 @Jassy930
### 供应商预设修复
- 修复 OpenClaw `/v1` 前缀双重路径问题
- Opus 定价修正($15/$75 → $5/$25)并升级到 4.6
- AIGoCode URL 统一为 `https://api.aigocode.com`
- Zhipu GLM 移除过时合作伙伴状态
- 新建 Claude 供应商时 API Key 输入框可见性恢复
- 非活跃供应商隐藏快速开关,显示上下文感知的 JSON 编辑器提示
### OMO 修复
- omo-slim 分类检查补齐(add/form/mutation 路径)
- OMO Slim 供应商变更后正确失效查询缓存
- OMO agent/category 推荐模型与上游源同步
- "填充推荐"按钮失败时增加 toast 反馈
- 移除 OMO/OMO Slim 最后一个供应商的删除限制
- OpenCode 未配置模型时拒绝保存(#932,感谢 @yovinchen
### OpenClaw 修复
- 修复 25 个缺失 i18n key、替换 key={index} 为稳定 ID、深链接 additive 合并等代码审查问题
- EnvPanel 健壮性增强(NaN 守卫、使用条目键名而非数组索引)
- i18n 重复键合并,恢复供应商表单翻译
### 平台修复
- Windows 静默启动时窗口闪烁(#901,感谢 @funnytime75
- 标题栏暗黑模式跟随主题(#903,感谢 @funnytime75
- Windows Skills 路径分隔符匹配(#868,感谢 @stmoonar
- WSL 辅助函数条件编译
### UI 修复
- 工具栏高度裁切导致 AppSwitcher 被遮挡
- 有新版本时显示更新徽章而非绿色对勾
- 仅 Claude/Codex 应用显示会话管理器按钮
- SQL 导入/导出卡片暗黑模式样式统一(#1067,感谢 @SaladDay
### 其他修复
- 会话管理器硬编码中文字符串替换为 i18n key
- Skill 文档 URL 分支和路径修正(#977,感谢 @yovinchen
- OpenCode install.sh 安装路径检测补齐(#988,感谢 @zhu-jl18
- Skill ZIP 符号链接解析修复(#1040,感谢 @yovinchen
- MCP 表单补齐 OpenCode 复选框(#1026,感谢 @yovinchen
- useProvidersQuery 中自动导入副作用移除
---
## 性能优化
- 会话面板并行目录扫描 + 头尾 JSONL 读取,大幅提升会话列表加载速度
- 移除 Tauri 本地 IPC 不必要的 query cache,减少内存占用
---
## 文档
- 赞助商更新:SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax
- 新增用户手册(#979,感谢 @yovinchen
---
## 说明与注意事项
- **OpenClaw 为新支持的应用**:需要先安装 OpenClaw CLI 才能使用相关功能。
- **部分键值合并是重要架构变更**:供应商切换不再全量覆写配置文件,而是仅合并供应商相关的键值。如果你之前依赖全量覆写行为,请注意此变更。
- **自动导入已改为手动**:启动时不再自动导入外部配置,请在需要时手动点击"导入当前配置"。
- **OMO 与 OMO Slim 互斥**:同一时间只能启用其中一个,切换时另一个会自动禁用。
- **备份功能默认开启**:运行时每小时自动备份,可在备份面板调整策略。
---
## 特别感谢
感谢以下贡献者为本版本做出的贡献!
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.11.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.11.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.11.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.11.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+3 -3
View File
@@ -99,9 +99,9 @@
## 版本信息
- 文档版本:v3.10.3
- 最后更新:2026-02-09
- 适用于 CC Switch v3.10.0+
- 文档版本:v3.11.0
- 最后更新:2026-02-26
- 适用于 CC Switch v3.11.0+
## 贡献
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.10.3",
"version": "3.11.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.10.3"
version = "3.11.0"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.10.3"
version = "3.11.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
-60
View File
@@ -352,49 +352,6 @@ impl FromStr for AppType {
}
}
/// 通用配置片段(按应用分治)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gemini: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option<String>,
}
impl CommonConfigSnippets {
/// 获取指定应用的通用配置片段
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
AppType::Claude => self.claude.as_ref(),
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
}
}
/// 设置指定应用的通用配置片段
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
match app {
AppType::Claude => self.claude = snippet,
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
}
}
}
/// 多应用配置结构(向后兼容)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultiAppConfig {
@@ -412,12 +369,6 @@ pub struct MultiAppConfig {
/// Claude Skills 配置
#[serde(default)]
pub skills: SkillStore,
/// 通用配置片段(按应用分治)
#[serde(default)]
pub common_config_snippets: CommonConfigSnippets,
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_common_config_snippet: Option<String>,
}
fn default_version() -> u32 {
@@ -439,8 +390,6 @@ impl Default for MultiAppConfig {
mcp: McpRoot::default(),
prompts: PromptRoot::default(),
skills: SkillStore::default(),
common_config_snippets: CommonConfigSnippets::default(),
claude_common_config_snippet: None,
}
}
}
@@ -534,15 +483,6 @@ impl MultiAppConfig {
updated = true;
}
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
log::info!(
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
);
config.common_config_snippets.claude = Some(old_claude_snippet);
updated = true;
}
if updated {
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
config.save()?;
-136
View File
@@ -7,7 +7,6 @@ use tauri_plugin_opener::OpenerExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
@@ -16,18 +15,6 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
use std::str::FromStr;
fn invalid_json_format_error(error: serde_json::Error) -> String {
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
match lang.as_str() {
"en" => format!("Invalid JSON format: {error}"),
"ja" => format!("JSON形式が無効です: {error}"),
_ => format!("无效的 JSON 格式: {error}"),
}
}
#[tauri::command]
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
@@ -163,126 +150,3 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
Ok(true)
}
#[tauri::command]
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
state
.db
.get_config_snippet("claude")
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
state
.db
.set_config_snippet("claude", value)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_common_config_snippet(
app_type: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
state
.db
.get_config_snippet(&app_type)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_common_config_snippet(
app_type: String,
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
if !snippet.trim().is_empty() {
match app_type.as_str() {
"claude" | "gemini" | "omo" | "omo-slim" => {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(invalid_json_format_error)?;
}
"codex" => {}
_ => {}
}
}
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
state
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
if app_type == "omo"
&& state
.db
.get_current_omo_provider("opencode", "omo")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(
state.inner(),
&crate::services::omo::STANDARD,
)
.map_err(|e| e.to_string())?;
}
if app_type == "omo-slim"
&& state
.db
.get_current_omo_provider("opencode", "omo-slim")
.map_err(|e| e.to_string())?
.is_some()
{
crate::services::OmoService::write_config_to_file(
state.inner(),
&crate::services::omo::SLIM,
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn extract_common_config_snippet(
appType: String,
settingsConfig: Option<String>,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<String, String> {
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
let settings: serde_json::Value =
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app,
&settings,
)
.map_err(|e| e.to_string());
}
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
.map_err(|e| e.to_string())
}
+24
View File
@@ -123,6 +123,24 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
// ─── Database backup management ─────────────────────────────
/// Manually create a database backup
#[tauri::command]
pub async fn create_db_backup(state: State<'_, AppState>) -> Result<String, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || match db.backup_database_file()? {
Some(path) => Ok(path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default()),
None => Err(AppError::Config(
"Database file not found, backup skipped".to_string(),
)),
})
.await
.map_err(|e| format!("Backup failed: {e}"))?
.map_err(|e: AppError| e.to_string())
}
/// List all database backup files
#[tauri::command]
pub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {
@@ -150,3 +168,9 @@ pub fn rename_db_backup(
) -> Result<String, String> {
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
}
/// Delete a database backup file
#[tauri::command]
pub fn delete_db_backup(filename: String) -> Result<(), String> {
Database::delete_backup(&filename).map_err(|e| e.to_string())
}
+1 -6
View File
@@ -657,6 +657,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
(None, Some("not installed or not executable".to_string()))
}
#[cfg(target_os = "windows")]
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
"claude" => crate::settings::get_claude_override_dir(),
@@ -694,12 +695,6 @@ fn wsl_distro_from_path(path: &Path) -> Option<String> {
}
}
/// 非 Windows 平台不支持 WSL 路径解析
#[cfg(not(target_os = "windows"))]
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
None
}
/// 打开指定提供商的终端
///
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
-26
View File
@@ -36,19 +36,6 @@ pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), Strin
Ok(())
}
#[tauri::command]
pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
let count = providers
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
Ok(count)
}
// ── OMO Slim commands ───────────────────────────────────────
#[tauri::command]
@@ -84,16 +71,3 @@ pub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(),
OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_omo_slim_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
let providers = state
.db
.get_all_providers("opencode")
.map_err(|e| e.to_string())?;
let count = providers
.values()
.filter(|p| p.category.as_deref() == Some("omo-slim"))
.count();
Ok(count)
}
+7 -21
View File
@@ -97,27 +97,7 @@ pub fn switch_provider(
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
let imported = ProviderService::import_default_config(state, app_type.clone())?;
if imported {
// Extract common config snippet (mirrors old startup logic in lib.rs)
if state
.db
.get_config_snippet(app_type.as_str())
.ok()
.flatten()
.is_none()
{
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
let _ = state
.db
.set_config_snippet(app_type.as_str(), Some(snippet));
}
_ => {}
}
}
}
let imported = ProviderService::import_default_config(state, app_type)?;
Ok(imported)
}
@@ -187,6 +167,12 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub async fn test_api_endpoints(
urls: Vec<String>,
+159
View File
@@ -1,5 +1,7 @@
use regex::Regex;
use std::sync::LazyLock;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
use crate::config::write_text_file;
use crate::openclaw_config::get_openclaw_dir;
@@ -147,6 +149,141 @@ pub async fn write_daily_memory_file(filename: String, content: String) -> Resul
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
}
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
if i >= s.len() {
return s.len();
}
while !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Search result for daily memory full-text search.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyMemorySearchResult {
pub filename: String,
pub date: String,
pub size_bytes: u64,
pub modified_at: u64,
pub snippet: String,
pub match_count: usize,
}
/// Full-text search across all daily memory files.
///
/// Performs case-insensitive search on both the date field and file content.
/// Returns results sorted by filename descending (newest first), each with a
/// snippet showing ~120 characters of context around the first match.
#[tauri::command]
pub async fn search_daily_memory_files(
query: String,
) -> Result<Vec<DailyMemorySearchResult>, String> {
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
if !memory_dir.exists() || query.is_empty() {
return Ok(Vec::new());
}
let query_lower = query.to_lowercase();
let mut results: Vec<DailyMemorySearchResult> = Vec::new();
let entries = std::fs::read_dir(&memory_dir)
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".md") {
continue;
}
let meta = match entry.metadata() {
Ok(m) if m.is_file() => m,
_ => continue,
};
let date = name.trim_end_matches(".md").to_string();
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
let content_lower = content.to_lowercase();
// Count matches in content
let content_matches: Vec<usize> = content_lower
.match_indices(&query_lower)
.map(|(i, _)| i)
.collect();
// Also check date field
let date_matches = date.to_lowercase().contains(&query_lower);
if content_matches.is_empty() && !date_matches {
continue;
}
// Build snippet around first content match (~120 chars of context)
let snippet = if let Some(&first_pos) = content_matches.first() {
let start = if first_pos > 50 {
floor_char_boundary(&content, first_pos - 50)
} else {
0
};
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
let mut s = String::new();
if start > 0 {
s.push_str("...");
}
s.push_str(&content[start..end]);
if end < content.len() {
s.push_str("...");
}
s
} else {
// Date-only match — use beginning of file as preview
let end = ceil_char_boundary(&content, 120.min(content.len()));
let mut s = content[..end].to_string();
if end < content.len() {
s.push_str("...");
}
s
};
let size_bytes = meta.len();
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
results.push(DailyMemorySearchResult {
filename: name,
date,
size_bytes,
modified_at,
snippet,
match_count: content_matches.len(),
});
}
// Sort by filename descending (newest date first)
results.sort_by(|a, b| b.filename.cmp(&a.filename));
Ok(results)
}
/// Delete a daily memory file (idempotent).
#[tauri::command]
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
@@ -201,3 +338,25 @@ pub async fn write_workspace_file(filename: String, content: String) -> Result<(
write_text_file(&path, &content)
.map_err(|e| format!("Failed to write workspace file {filename}: {e}"))
}
/// Open the workspace or memory directory in the system file manager.
/// `subdir`: "workspace" opens `~/.openclaw/workspace/`,
/// "memory" opens `~/.openclaw/workspace/memory/`.
#[tauri::command]
pub async fn open_workspace_directory(handle: AppHandle, subdir: String) -> Result<bool, String> {
let dir = match subdir.as_str() {
"memory" => get_openclaw_dir().join("workspace").join("memory"),
_ => get_openclaw_dir().join("workspace"),
};
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {e}"))?;
}
handle
.opener()
.open_path(dir.to_string_lossy().to_string(), None::<String>)
.map_err(|e| format!("Failed to open directory: {e}"))?;
Ok(true)
}
+27 -2
View File
@@ -5,7 +5,7 @@
use super::{lock_conn, Database};
use crate::config::get_app_config_dir;
use crate::error::AppError;
use chrono::Utc;
use chrono::{Local, Utc};
use rusqlite::backup::Backup;
use rusqlite::types::ValueRef;
use rusqlite::Connection;
@@ -182,7 +182,7 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let base_id = format!("db_backup_{}", Local::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone();
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
@@ -531,4 +531,29 @@ impl Database {
log::info!("Renamed backup: {old_filename} -> {new_filename}");
Ok(new_filename)
}
/// Delete a backup file permanently.
pub fn delete_backup(filename: &str) -> Result<(), AppError> {
// Validate filename (path traversal + .db suffix)
if filename.contains("..")
|| filename.contains('/')
|| filename.contains('\\')
|| !filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
let backup_path = get_app_config_dir().join("backups").join(filename);
if !backup_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {filename}"
)));
}
fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?;
log::info!("Deleted backup: {filename}");
Ok(())
}
}
-2
View File
@@ -4,7 +4,6 @@
pub mod failover;
pub mod mcp;
pub mod omo;
pub mod prompts;
pub mod providers;
pub mod proxy;
@@ -16,4 +15,3 @@ pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
pub use failover::FailoverQueueItem;
pub use omo::OmoGlobalConfig;
-77
View File
@@ -1,77 +0,0 @@
use crate::database::Database;
use crate::error::AppError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoGlobalConfig {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sisyphus_agent: Option<serde_json::Value>,
#[serde(default)]
pub disabled_agents: Vec<String>,
#[serde(default)]
pub disabled_mcps: Vec<String>,
#[serde(default)]
pub disabled_hooks: Vec<String>,
#[serde(default)]
pub disabled_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lsp: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_task: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_automation_engine: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_code: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub other_fields: Option<serde_json::Value>,
pub updated_at: String,
}
impl Default for OmoGlobalConfig {
fn default() -> Self {
Self {
id: "global".to_string(),
schema_url: None,
sisyphus_agent: None,
disabled_agents: vec![],
disabled_mcps: vec![],
disabled_hooks: vec![],
disabled_skills: vec![],
lsp: None,
experimental: None,
background_task: None,
browser_automation_engine: None,
claude_code: None,
other_fields: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}
}
}
impl Database {
pub fn get_omo_global_config(&self, key: &str) -> Result<OmoGlobalConfig, AppError> {
let json_str = self.get_setting(key)?;
match json_str {
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
.map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))),
None => Ok(OmoGlobalConfig::default()),
}
}
pub fn save_omo_global_config(
&self,
key: &str,
config: &OmoGlobalConfig,
) -> Result<(), AppError> {
let json_str = serde_json::to_string(config)
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
self.set_setting(key, &json_str)?;
Ok(())
}
}
+13
View File
@@ -375,6 +375,19 @@ impl Database {
params![app_type, category],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// OMO ↔ OMO Slim mutually exclusive: deactivate the opposite category
let opposite = match category {
"omo" => Some("omo-slim"),
"omo-slim" => Some("omo"),
_ => None,
};
if let Some(opp) = opposite {
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
params![app_type, opp],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
let updated = tx
.execute(
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3",
-25
View File
@@ -38,31 +38,6 @@ impl Database {
Ok(())
}
// --- Config Snippets 辅助方法 ---
/// 获取通用配置片段
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
self.get_setting(&format!("common_config_{app_type}"))
}
/// 设置通用配置片段
pub fn set_config_snippet(
&self,
app_type: &str,
snippet: Option<String>,
) -> Result<(), AppError> {
let key = format!("common_config_{app_type}");
if let Some(value) = snippet {
self.set_setting(&key, &value)
} else {
// 如果为 None 则删除
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
// --- 全局出站代理 ---
/// 全局代理 URL 的存储键名
-33
View File
@@ -58,9 +58,6 @@ impl Database {
// 4. 迁移 Skills
Self::migrate_skills(tx, config)?;
// 5. 迁移 Common Config
Self::migrate_common_config(tx, config)?;
Ok(())
}
@@ -212,34 +209,4 @@ impl Database {
Ok(())
}
/// 迁移通用配置片段
fn migrate_common_config(
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
if let Some(snippet) = &config.common_config_snippets.claude {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_claude", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.codex {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_codex", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.gemini {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_gemini", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
Ok(())
}
}
-1
View File
@@ -33,7 +33,6 @@ mod tests;
// DAO 类型导出供外部使用
pub use dao::FailoverQueueItem;
pub use dao::OmoGlobalConfig;
use crate::config::get_app_config_dir;
use crate::error::AppError;
-4
View File
@@ -513,8 +513,6 @@ fn schema_dry_run_does_not_write_to_disk() {
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should succeed without any file I/O errors
@@ -563,8 +561,6 @@ fn dry_run_validates_schema_compatibility() {
mcp: Default::default(),
prompts: Default::default(),
skills: Default::default(),
common_config_snippets: Default::default(),
claude_common_config_snippet: None,
};
// Dry-run should validate the full migration path
+5 -7
View File
@@ -845,12 +845,8 @@ pub fn run() {
commands::get_skills_migration_result,
commands::get_app_config_path,
commands::open_app_config_folder,
commands::get_claude_common_config_snippet,
commands::set_claude_common_config_snippet,
commands::get_common_config_snippet,
commands::set_common_config_snippet,
commands::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::patch_claude_live_settings,
commands::get_settings,
commands::save_settings,
commands::get_rectifier_config,
@@ -915,9 +911,11 @@ pub fn run() {
commands::save_file_dialog,
commands::open_file_dialog,
commands::open_zip_file_dialog,
commands::create_db_backup,
commands::list_db_backups,
commands::restore_db_backup,
commands::rename_db_backup,
commands::delete_db_backup,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
@@ -1039,11 +1037,9 @@ pub fn run() {
commands::set_window_theme,
commands::read_omo_local_file,
commands::get_current_omo_provider_id,
commands::get_omo_provider_count,
commands::disable_current_omo,
commands::read_omo_slim_local_file,
commands::get_current_omo_slim_provider_id,
commands::get_omo_slim_provider_count,
commands::disable_current_omo_slim,
// Workspace files (OpenClaw)
commands::read_workspace_file,
@@ -1053,6 +1049,8 @@ pub fn run() {
commands::read_daily_memory_file,
commands::write_daily_memory_file,
commands::delete_daily_memory_file,
commands::search_daily_memory_files,
commands::open_workspace_directory,
]);
let app = builder
+5
View File
@@ -235,6 +235,11 @@ pub struct ProviderMeta {
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Claude 认证字段名(仅 Claude 供应商使用)
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
}
impl ProviderManager {
-150
View File
@@ -1,9 +1,5 @@
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
use crate::app_config::{AppType, MultiAppConfig};
use crate::error::AppError;
use crate::provider::Provider;
use chrono::Utc;
use serde_json::Value;
use std::fs;
use std::path::Path;
@@ -82,150 +78,4 @@ impl ConfigService {
Ok(())
}
/// 同步当前供应商到对应的 live 配置。
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
Ok(())
}
fn sync_current_provider_for_app(
config: &mut MultiAppConfig,
app_type: &AppType,
) -> Result<(), AppError> {
let (current_id, provider) = {
let manager = match config.get_manager(app_type) {
Some(manager) => manager,
None => return Ok(()),
};
if manager.current.is_empty() {
return Ok(());
}
let current_id = manager.current.clone();
let provider = match manager.providers.get(&current_id) {
Some(provider) => provider.clone(),
None => {
log::warn!(
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
);
return Ok(());
}
};
(current_id, provider)
};
match app_type {
AppType::Codex => Self::sync_codex_live(config, &current_id, &provider)?,
AppType::Claude => Self::sync_claude_live(config, &current_id, &provider)?,
AppType::Gemini => Self::sync_gemini_live(config, &current_id, &provider)?,
AppType::OpenCode => {
// OpenCode uses additive mode, no live sync needed
// OpenCode providers are managed directly in the config file
}
AppType::OpenClaw => {
// OpenClaw uses additive mode, no live sync needed
// OpenClaw providers are managed directly in the config file
}
}
Ok(())
}
fn sync_codex_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
let settings = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
})?;
let auth = settings.get("auth").ok_or_else(|| {
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
})?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
if let Some(target) = manager.providers.get_mut(provider_id) {
if let Some(obj) = target.settings_config.as_object_mut() {
obj.insert(
"config".to_string(),
serde_json::Value::String(cfg_text_after),
);
}
}
}
Ok(())
}
fn sync_claude_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::config::{read_json_file, write_json_file};
let settings_path = crate::config::get_claude_settings_path();
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
write_json_file(&settings_path, &settings)?;
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
fn sync_gemini_live(
config: &mut MultiAppConfig,
provider_id: &str,
provider: &Provider,
) -> Result<(), AppError> {
use crate::gemini_config::{env_to_json, read_gemini_env};
ProviderService::write_gemini_live(provider)?;
// 读回实际写入的内容并更新到配置中(包含 settings.json
let live_after_env = read_gemini_env()?;
let settings_path = crate::gemini_config::get_gemini_settings_path();
let live_after_config = if settings_path.exists() {
crate::config::read_json_file(&settings_path)?
} else {
serde_json::json!({})
};
let mut live_after = env_to_json(&live_after_env);
if let Some(obj) = live_after.as_object_mut() {
obj.insert("config".to_string(), live_after_config);
}
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
Ok(())
}
}
+44 -213
View File
@@ -1,5 +1,4 @@
use crate::config::write_json_file;
use crate::database::OmoGlobalConfig;
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::store::AppState;
@@ -13,12 +12,11 @@ pub struct OmoLocalFileData {
pub agents: Option<Value>,
pub categories: Option<Value>,
pub other_fields: Option<Value>,
pub global: OmoGlobalConfig,
pub file_path: String,
pub last_modified: Option<String>,
}
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
// ── Variant descriptor ─────────────────────────────────────────
@@ -28,9 +26,7 @@ pub struct OmoVariant {
pub provider_prefix: &'static str,
pub plugin_name: &'static str,
pub plugin_prefix: &'static str,
pub known_keys: &'static [&'static str],
pub has_categories: bool,
pub config_key: &'static str,
pub label: &'static str,
pub import_label: &'static str,
}
@@ -41,23 +37,7 @@ pub const STANDARD: OmoVariant = OmoVariant {
provider_prefix: "omo-",
plugin_name: "oh-my-opencode@latest",
plugin_prefix: "oh-my-opencode",
known_keys: &[
"$schema",
"agents",
"categories",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"disabled_skills",
"lsp",
"experimental",
"background_task",
"browser_automation_engine",
"claude_code",
],
has_categories: true,
config_key: "common_config_omo",
label: "OMO",
import_label: "Imported",
};
@@ -68,18 +48,7 @@ pub const SLIM: OmoVariant = OmoVariant {
provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest",
plugin_prefix: "oh-my-opencode-slim",
known_keys: &[
"$schema",
"agents",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"lsp",
"experimental",
],
has_categories: false,
config_key: "common_config_omo_slim",
label: "OMO Slim",
import_label: "Imported Slim",
};
@@ -135,47 +104,6 @@ impl OmoService {
other
}
fn extract_string_array(val: &Value) -> Vec<String> {
val.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn merge_global_from_obj(obj: &Map<String, Value>, global: &mut OmoGlobalConfig) {
if let Some(v) = obj.get("$schema") {
global.schema_url = v.as_str().map(|s| s.to_string());
}
for (key, target) in [
("disabled_agents", &mut global.disabled_agents),
("disabled_mcps", &mut global.disabled_mcps),
("disabled_hooks", &mut global.disabled_hooks),
("disabled_skills", &mut global.disabled_skills),
] {
if let Some(v) = obj.get(key) {
*target = Self::extract_string_array(v);
}
}
for (key, target) in [
("sisyphus_agent", &mut global.sisyphus_agent),
("lsp", &mut global.lsp),
("experimental", &mut global.experimental),
("background_task", &mut global.background_task),
(
"browser_automation_engine",
&mut global.browser_automation_engine,
),
("claude_code", &mut global.claude_code),
] {
if let Some(v) = obj.get(key) {
*target = Some(v.clone());
}
}
}
// ── Merge helpers ──────────────────────────────────────
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
@@ -184,15 +112,6 @@ impl OmoService {
}
}
fn insert_string_array(result: &mut Map<String, Value>, key: &str, values: &[String]) {
if !values.is_empty() {
result.insert(
key.to_string(),
serde_json::to_value(values).unwrap_or(Value::Array(vec![])),
);
}
}
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
if let Some(Value::Object(map)) = value {
for (k, v) in map {
@@ -214,9 +133,7 @@ impl OmoService {
}
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
let global = state.db.get_omo_global_config(v.config_key)?;
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
let profile_data = current_omo.as_ref().map(|p| {
let agents = p.settings_config.get("agents").cloned();
let categories = if v.has_categories {
@@ -225,15 +142,10 @@ impl OmoService {
None
};
let other_fields = p.settings_config.get("otherFields").cloned();
let use_common_config = p
.settings_config
.get("useCommonConfig")
.and_then(|val| val.as_bool())
.unwrap_or(true);
(agents, categories, other_fields, use_common_config)
(agents, categories, other_fields)
});
let merged = Self::merge_config(v, &global, profile_data.as_ref());
let merged = Self::build_config(v, profile_data.as_ref());
let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() {
@@ -241,56 +153,20 @@ impl OmoService {
}
write_json_file(&config_path, &merged)?;
crate::opencode_config::add_plugin(v.plugin_name)?;
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
}
fn merge_config(
v: &OmoVariant,
global: &OmoGlobalConfig,
profile_data: Option<&OmoProfileData>,
) -> Value {
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
let mut result = Map::new();
let use_common_config = profile_data.map(|(_, _, _, uc)| *uc).unwrap_or(true);
if use_common_config {
if let Some(url) = &global.schema_url {
result.insert("$schema".to_string(), Value::String(url.clone()));
}
Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent);
Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents);
Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps);
Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks);
if v.has_categories {
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
Self::insert_opt_value(&mut result, "background_task", &global.background_task);
Self::insert_opt_value(
&mut result,
"browser_automation_engine",
&global.browser_automation_engine,
);
Self::insert_opt_value(&mut result, "claude_code", &global.claude_code);
}
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
Self::insert_object_entries(&mut result, global.other_fields.as_ref());
}
if let Some((agents, categories, other_fields, _)) = profile_data {
if let Some((agents, categories, other_fields)) = profile_data {
Self::insert_object_entries(&mut result, other_fields.as_ref());
Self::insert_opt_value(&mut result, "agents", agents);
if v.has_categories {
Self::insert_opt_value(&mut result, "categories", categories);
}
Self::insert_object_entries(&mut result, other_fields.as_ref());
}
Value::Object(result)
}
@@ -310,18 +186,12 @@ impl OmoService {
settings.insert("categories".to_string(), categories.clone());
}
}
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields_with_keys(&obj, v.known_keys);
let other = Self::extract_other_fields_with_keys(&obj, &["agents", "categories"]);
if !other.is_empty() {
settings.insert("otherFields".to_string(), Value::Object(other));
}
let mut global = state.db.get_omo_global_config(v.config_key)?;
Self::merge_global_from_obj(&obj, &mut global);
global.updated_at = chrono::Utc::now().to_rfc3339();
state.db.save_omo_global_config(v.config_key, &global)?;
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
let name = format!(
"{} {}",
@@ -384,22 +254,17 @@ impl OmoService {
None
};
let other = Self::extract_other_fields_with_keys(obj, v.known_keys);
let other = Self::extract_other_fields_with_keys(obj, &["agents", "categories"]);
let other_fields = if other.is_empty() {
None
} else {
Some(Value::Object(other))
};
let mut global = OmoGlobalConfig::default();
Self::merge_global_from_obj(obj, &mut global);
global.other_fields = other_fields.clone();
OmoLocalFileData {
agents,
categories,
other_fields,
global,
file_path,
last_modified,
}
@@ -482,26 +347,24 @@ mod tests {
}
#[test]
fn test_merge_config_empty() {
let global = OmoGlobalConfig::default();
let merged = OmoService::merge_config(&STANDARD, &global, None);
fn test_build_config_empty() {
let merged = OmoService::build_config(&STANDARD, None);
assert!(merged.is_object());
assert!(merged.as_object().unwrap().is_empty());
}
#[test]
fn test_merge_config_with_profile() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
fn test_build_config_with_profile() {
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, true);
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
let other_fields = Some(serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["explore"]
}));
let profile_data = (agents, categories, other_fields);
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert_eq!(obj["$schema"], "https://example.com/schema.json");
@@ -511,28 +374,7 @@ mod tests {
}
#[test]
fn test_merge_config_without_common_config() {
let global = OmoGlobalConfig {
schema_url: Some("https://example.com/schema.json".to_string()),
disabled_agents: vec!["explore".to_string()],
..Default::default()
};
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = None;
let profile_data = (agents, categories, other_fields, false);
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("$schema"));
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() {
fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() {
let obj = serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["oracle"],
@@ -555,64 +397,53 @@ mod tests {
None,
);
// All non-agents/categories fields should be in other_fields
let other = data.other_fields.unwrap();
let other_obj = other.as_object().unwrap();
assert_eq!(
data.global.schema_url.as_deref(),
Some("https://example.com/schema.json")
other_obj.get("$schema").unwrap(),
"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 }
}))
other_obj.get("disabled_agents").unwrap(),
&serde_json::json!(["oracle"])
);
assert_eq!(data.global.other_fields, data.other_fields);
assert_eq!(
other_obj.get("custom_top_level").unwrap(),
&serde_json::json!({"enabled": true})
);
// agents and categories should NOT be in other_fields
assert!(!other_obj.contains_key("agents"));
assert!(!other_obj.contains_key("categories"));
}
#[test]
fn test_merge_config_ignores_non_object_other_fields() {
let global = OmoGlobalConfig {
other_fields: Some(serde_json::json!(["global_non_object"])),
..Default::default()
};
fn test_build_config_ignores_non_object_other_fields() {
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 profile_data = (agents, categories, other_fields);
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
let merged = OmoService::build_config(&STANDARD, 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"));
}
#[test]
fn test_merge_config_slim_excludes_categories_and_extra_fields() {
let global = OmoGlobalConfig {
schema_url: Some("https://slim.schema".to_string()),
disabled_agents: vec!["oracle".to_string()],
disabled_skills: vec!["playwright".to_string()],
background_task: Some(serde_json::json!({"key": "val"})),
browser_automation_engine: Some(serde_json::json!({"provider": "pw"})),
claude_code: Some(serde_json::json!({"mcp": true})),
..Default::default()
};
fn test_build_config_slim_excludes_categories() {
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
let other_fields = None;
let profile_data = (agents, categories, other_fields, true);
let other_fields = Some(serde_json::json!({
"$schema": "https://slim.schema",
"disabled_agents": ["oracle"]
}));
let profile_data = (agents, categories, other_fields);
let merged = OmoService::merge_config(&SLIM, &global, Some(&profile_data));
let merged = OmoService::build_config(&SLIM, Some(&profile_data));
let obj = merged.as_object().unwrap();
// Slim should NOT include these
assert!(!obj.contains_key("disabled_skills"));
assert!(!obj.contains_key("background_task"));
assert!(!obj.contains_key("browser_automation_engine"));
assert!(!obj.contains_key("claude_code"));
// Slim should NOT include categories
assert!(!obj.contains_key("categories"));
// Slim SHOULD include these
+434 -1
View File
@@ -237,6 +237,439 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
Ok(())
}
// ============================================================================
// Key fields definitions for partial merge
// ============================================================================
/// Claude env-level key fields that belong to the provider.
/// When adding a new field here, also update backfill_claude_key_fields().
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
// --- API auth & endpoint ---
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
// --- Model selection ---
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
// --- AWS Bedrock ---
"CLAUDE_CODE_USE_BEDROCK",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_REGION",
"AWS_PROFILE",
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
// --- Google Vertex AI ---
"CLAUDE_CODE_USE_VERTEX",
"ANTHROPIC_VERTEX_PROJECT_ID",
"CLOUD_ML_REGION",
// --- Microsoft Foundry ---
"CLAUDE_CODE_USE_FOUNDRY",
// --- Provider behavior ---
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
"API_TIMEOUT_MS",
"DISABLE_PROMPT_CACHING",
];
/// Claude top-level key fields (legacy + modern format).
/// When adding a new field here, also update backfill_claude_key_fields().
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
"apiBaseUrl", // legacy
"primaryModel", // legacy
"smallFastModel", // legacy
"model", // modern
"apiKey", // Bedrock API Key auth
];
/// Codex TOML key fields.
/// When adding a new field here, also update backfill_codex_key_fields().
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
"model_provider",
"model",
"model_reasoning_effort",
"review_model",
"plan_mode_reasoning_effort",
];
/// Gemini env-level key fields.
/// When adding a new field here, also update backfill_gemini_key_fields().
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_MODEL",
"GOOGLE_API_KEY",
];
// ============================================================================
// Partial merge: write only key fields to live config
// ============================================================================
/// Write only provider-specific key fields to live configuration,
/// preserving all other user settings in the live file.
///
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
/// - `switch_normal()` — switching providers
/// - `sync_current_to_live()` — startup sync
/// - `add()` / `update()` when the provider is current
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => write_claude_live_partial(provider),
AppType::Codex => write_codex_live_partial(provider),
AppType::Gemini => write_gemini_live_partial(provider),
// Additive mode apps still use full snapshot
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
}
}
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
/// Used for user-level preferences (attribution, thinking, etc.) that are
/// independent of the active provider.
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
let path = get_claude_settings_path();
let mut live = if path.exists() {
read_json_file(&path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
json_merge_patch(&mut live, &patch);
let settings = sanitize_claude_settings_for_live(&live);
write_json_file(&path, &settings)?;
Ok(())
}
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
fn json_merge_patch(target: &mut Value, patch: &Value) {
if let Some(patch_obj) = patch.as_object() {
if !target.is_object() {
*target = json!({});
}
let target_obj = target.as_object_mut().unwrap();
for (key, value) in patch_obj {
if value.is_null() {
target_obj.remove(key);
} else if value.is_object() {
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
json_merge_patch(entry, value);
// Clean up empty container objects
if entry.as_object().is_some_and(|o| o.is_empty()) {
target_obj.remove(key);
}
} else {
target_obj.insert(key.clone(), value.clone());
}
}
}
}
/// Claude: merge only key env and top-level fields into live settings.json
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
let path = get_claude_settings_path();
// 1. Read existing live config (start from empty if file doesn't exist)
let mut live = if path.exists() {
read_json_file(&path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// 2. Ensure live.env exists as an object
if !live.get("env").is_some_and(|v| v.is_object()) {
live.as_object_mut()
.unwrap()
.insert("env".into(), json!({}));
}
// 3. Clear key env fields from live, then write from provider
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
for key in CLAUDE_KEY_ENV_FIELDS {
live_env.remove(*key);
}
if let Some(provider_env) = provider
.settings_config
.get("env")
.and_then(|v| v.as_object())
{
for key in CLAUDE_KEY_ENV_FIELDS {
if let Some(value) = provider_env.get(*key) {
live_env.insert(key.to_string(), value.clone());
}
}
}
// 4. Handle top-level legacy key fields
let live_obj = live.as_object_mut().unwrap();
for key in CLAUDE_KEY_TOP_LEVEL {
live_obj.remove(*key);
}
if let Some(provider_obj) = provider.settings_config.as_object() {
for key in CLAUDE_KEY_TOP_LEVEL {
if let Some(value) = provider_obj.get(*key) {
live_obj.insert(key.to_string(), value.clone());
}
}
}
// 5. Sanitize and write
let settings = sanitize_claude_settings_for_live(&live);
write_json_file(&path, &settings)?;
Ok(())
}
/// Codex: replace auth.json entirely, partially merge config.toml key fields
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
let obj = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
// auth.json is entirely provider-specific, replace it wholesale
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
// Read existing config.toml (or start from empty)
let config_path = get_codex_config_path();
let existing_toml = if config_path.exists() {
std::fs::read_to_string(&config_path).unwrap_or_default()
} else {
String::new()
};
// Parse both existing and provider TOML
let mut live_doc = existing_toml
.parse::<toml_edit::DocumentMut>()
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
// Remove key fields from live doc
let live_root = live_doc.as_table_mut();
for key in CODEX_KEY_TOP_LEVEL {
live_root.remove(key);
}
live_root.remove("model_providers");
// Parse provider TOML and extract key fields
if !provider_config_str.is_empty() {
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
let provider_root = provider_doc.as_table();
// Copy key top-level fields from provider
for key in CODEX_KEY_TOP_LEVEL {
if let Some(item) = provider_root.get(key) {
live_root.insert(key, item.clone());
}
}
// Copy model_providers table from provider
if let Some(mp) = provider_root.get("model_providers") {
live_root.insert("model_providers", mp.clone());
}
}
}
// Write using atomic write
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
Ok(())
}
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
let auth_type = detect_gemini_auth_type(provider);
// 1. Read existing env from live .env file
let mut env_map = if get_gemini_env_path().exists() {
read_gemini_env().unwrap_or_default()
} else {
HashMap::new()
};
// 2. Remove key fields from existing env
for key in GEMINI_KEY_ENV_FIELDS {
env_map.remove(*key);
}
// 3. Extract key fields from provider and merge
if let Some(provider_env) = provider
.settings_config
.get("env")
.and_then(|v| v.as_object())
{
for key in GEMINI_KEY_ENV_FIELDS {
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
if !value.is_empty() {
env_map.insert(key.to_string(), value.to_string());
}
}
}
}
// 4. Handle auth type specific behavior
match auth_type {
GeminiAuthType::GoogleOfficial => {
// Google official uses OAuth, clear all env
env_map.clear();
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
// Validate and write env
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
}
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
use crate::gemini_config::get_gemini_settings_path;
let settings_path = get_gemini_settings_path();
if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_object() {
let mut merged = if settings_path.exists() {
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
if let (Some(merged_obj), Some(config_obj)) =
(merged.as_object_mut(), config_value.as_object())
{
for (k, v) in config_obj {
merged_obj.insert(k.clone(), v.clone());
}
}
write_json_file(&settings_path, &merged)?;
} else if !config_value.is_null() {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
}
// 6. Set security flag based on auth type
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
}
Ok(())
}
// ============================================================================
// Backfill: extract only key fields from live config
// ============================================================================
/// Extract only provider-specific key fields from a live config value.
///
/// Used during backfill to ensure the provider's `settings_config` converges
/// to containing only key fields over time.
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
match app_type {
AppType::Claude => backfill_claude_key_fields(live_config),
AppType::Codex => backfill_codex_key_fields(live_config),
AppType::Gemini => backfill_gemini_key_fields(live_config),
// Additive mode: return full config (no backfill needed)
_ => live_config.clone(),
}
}
fn backfill_claude_key_fields(live: &Value) -> Value {
let mut result = json!({});
let result_obj = result.as_object_mut().unwrap();
// Extract key env fields
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
let mut env_obj = serde_json::Map::new();
for key in CLAUDE_KEY_ENV_FIELDS {
if let Some(value) = live_env.get(*key) {
env_obj.insert(key.to_string(), value.clone());
}
}
if !env_obj.is_empty() {
result_obj.insert("env".to_string(), Value::Object(env_obj));
}
}
// Extract key top-level fields
if let Some(live_obj) = live.as_object() {
for key in CLAUDE_KEY_TOP_LEVEL {
if let Some(value) = live_obj.get(*key) {
result_obj.insert(key.to_string(), value.clone());
}
}
}
result
}
fn backfill_codex_key_fields(live: &Value) -> Value {
let mut result = json!({});
let result_obj = result.as_object_mut().unwrap();
// auth is entirely provider-specific — keep it as-is
if let Some(auth) = live.get("auth") {
result_obj.insert("auth".to_string(), auth.clone());
}
// Extract key TOML fields from config string
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
let mut new_doc = toml_edit::DocumentMut::new();
let new_root = new_doc.as_table_mut();
// Copy key top-level fields
for key in CODEX_KEY_TOP_LEVEL {
if let Some(item) = doc.as_table().get(key) {
new_root.insert(key, item.clone());
}
}
// Copy model_providers table
if let Some(mp) = doc.as_table().get("model_providers") {
new_root.insert("model_providers", mp.clone());
}
let toml_str = new_doc.to_string();
if !toml_str.trim().is_empty() {
result_obj.insert("config".to_string(), Value::String(toml_str));
}
}
}
result
}
fn backfill_gemini_key_fields(live: &Value) -> Value {
let mut result = json!({});
let result_obj = result.as_object_mut().unwrap();
// Extract key env fields
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
let mut env_obj = serde_json::Map::new();
for key in GEMINI_KEY_ENV_FIELDS {
if let Some(value) = live_env.get(*key) {
env_obj.insert(key.to_string(), value.clone());
}
}
if !env_obj.is_empty() {
result_obj.insert("env".to_string(), Value::Object(env_obj));
}
}
result
}
/// Sync all providers to live configuration (for additive mode apps)
///
/// Writes all providers from the database to the live configuration file.
@@ -286,7 +719,7 @@ 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)?;
write_live_partial(&app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
+24 -298
View File
@@ -27,11 +27,12 @@ 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_partial;
// Internal re-exports
use live::{
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
write_live_snapshot,
};
use usage::validate_usage_script;
@@ -84,47 +85,6 @@ mod tests {
assert_eq!(api_key, "token");
assert_eq!(base_url, "https://claude.example");
}
#[test]
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
let config_toml = r#"model_provider = "azure"
model = "gpt-4"
disable_response_storage = true
[model_providers.azure]
name = "Azure OpenAI"
base_url = "https://azure.example/v1"
wire_api = "responses"
[mcp_servers.my_server]
base_url = "http://localhost:8080"
"#;
let settings = json!({ "config": config_toml });
let extracted = ProviderService::extract_codex_common_config(&settings)
.expect("extract_codex_common_config should succeed");
assert!(
!extracted
.lines()
.any(|line| line.trim_start().starts_with("model_provider")),
"should remove top-level model_provider"
);
assert!(
!extracted
.lines()
.any(|line| line.trim_start().starts_with("model =")),
"should remove top-level model"
);
assert!(
!extracted.contains("[model_providers"),
"should remove entire model_providers table"
);
assert!(
extracted.contains("http://localhost:8080"),
"should keep mcp_servers.* base_url"
);
}
}
impl ProviderService {
@@ -173,10 +133,11 @@ impl ProviderService {
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
if app_type.is_additive_mode() {
// OMO providers use exclusive mode and write to dedicated config file.
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
// OMO / OMO Slim providers use exclusive mode and write to dedicated config file.
if matches!(app_type, AppType::OpenCode)
&& matches!(provider.category.as_deref(), Some("omo") | Some("omo-slim"))
{
// Do not auto-enable newly added OMO providers.
// Do not auto-enable newly added OMO / OMO Slim providers.
// Users must explicitly switch/apply an OMO provider to activate it.
return Ok(true);
}
@@ -191,7 +152,7 @@ impl ProviderService {
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
write_live_snapshot(&app_type, &provider)?;
write_live_partial(&app_type, &provider)?;
}
Ok(true)
@@ -272,7 +233,7 @@ impl ProviderService {
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
write_live_snapshot(&app_type, &provider)?;
write_live_partial(&app_type, &provider)?;
// Sync MCP
McpService::sync_all_enabled(state)?;
}
@@ -299,18 +260,6 @@ impl ProviderService {
state
.db
.is_omo_provider_current(app_type.as_str(), id, "omo")?;
let omo_count = state
.db
.get_all_providers(app_type.as_str())?
.values()
.filter(|p| p.category.as_deref() == Some("omo"))
.count();
if omo_count <= 1 && was_current {
return Err(AppError::Message(
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
@@ -326,18 +275,6 @@ impl ProviderService {
state
.db
.is_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
let slim_count = state
.db
.get_all_providers(app_type.as_str())?
.values()
.filter(|p| p.category.as_deref() == Some("omo-slim"))
.count();
if slim_count <= 1 && was_current {
return Err(AppError::Message(
"无法删除当前启用的最后一个 OMO Slim 配置,请先停用".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
@@ -552,6 +489,8 @@ impl ProviderService {
state,
&crate::services::omo::STANDARD,
)?;
// OMO ↔ OMO Slim mutually exclusive: remove Slim config
let _ = crate::services::OmoService::delete_config_file(&crate::services::omo::SLIM);
return Ok(SwitchResult::default());
}
@@ -561,6 +500,9 @@ impl ProviderService {
.db
.set_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?;
// OMO ↔ OMO Slim mutually exclusive: remove Standard config
let _ =
crate::services::OmoService::delete_config_file(&crate::services::omo::STANDARD);
return Ok(SwitchResult::default());
}
@@ -578,7 +520,9 @@ impl ProviderService {
// 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;
// Only extract key fields from live config for backfill
current_provider.settings_config =
backfill_key_fields(&app_type, &live_config);
if let Err(e) =
state.db.save_provider(app_type.as_str(), &current_provider)
{
@@ -602,11 +546,8 @@ 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 MCP
McpService::sync_all_enabled(state)?;
// Sync to live (partial merge: only key fields, preserving user settings)
write_live_partial(&app_type, provider)?;
Ok(result)
}
@@ -616,222 +557,6 @@ impl ProviderService {
sync_current_to_live(state)
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
pub fn extract_common_config_snippet(
state: &AppState,
app_type: AppType,
) -> Result<String, AppError> {
// Get current provider
let current_id = Self::current(state, app_type.clone())?;
if current_id.is_empty() {
return Err(AppError::Message("No current provider".to_string()));
}
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&current_id)
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
match app_type {
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
}
}
/// Extract common config snippet from a config value (e.g. editor content).
pub fn extract_common_config_snippet_from_settings(
app_type: AppType,
settings_config: &Value,
) -> Result<String, AppError> {
match app_type {
AppType::Claude => Self::extract_claude_common_config(settings_config),
AppType::Codex => Self::extract_codex_common_config(settings_config),
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
}
}
/// Extract common config for Claude (JSON format)
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
let mut config = settings.clone();
// Fields to exclude from common config
const ENV_EXCLUDES: &[&str] = &[
// Auth
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
// Models (5 fields)
"ANTHROPIC_MODEL",
"ANTHROPIC_REASONING_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
// Endpoint
"ANTHROPIC_BASE_URL",
];
const TOP_LEVEL_EXCLUDES: &[&str] = &[
"apiBaseUrl",
// Legacy model fields
"primaryModel",
"smallFastModel",
];
// Remove env fields
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
for key in ENV_EXCLUDES {
env.remove(*key);
}
// If env is empty after removal, remove the env object itself
if env.is_empty() {
config.as_object_mut().map(|obj| obj.remove("env"));
}
}
// Remove top-level fields
if let Some(obj) = config.as_object_mut() {
for key in TOP_LEVEL_EXCLUDES {
obj.remove(*key);
}
}
// Check if result is empty
if config.as_object().is_none_or(|obj| obj.is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for Codex (TOML format)
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
// Codex config is stored as { "auth": {...}, "config": "toml string" }
let config_toml = settings
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
if config_toml.is_empty() {
return Ok(String::new());
}
let mut doc = config_toml
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
// Remove provider-specific fields.
let root = doc.as_table_mut();
root.remove("model");
root.remove("model_provider");
// Legacy/alt formats might use a top-level base_url.
root.remove("base_url");
// Remove entire model_providers table (provider-specific configuration)
root.remove("model_providers");
// Clean up multiple empty lines (keep at most one blank line).
let mut cleaned = String::new();
let mut blank_run = 0usize;
for line in doc.to_string().lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run <= 1 {
cleaned.push('\n');
}
continue;
}
blank_run = 0;
cleaned.push_str(line);
cleaned.push('\n');
}
Ok(cleaned.trim().to_string())
}
/// Extract common config for Gemini (JSON format)
///
/// Extracts `.env` values while excluding provider-specific credentials:
/// - GOOGLE_GEMINI_BASE_URL
/// - GEMINI_API_KEY
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();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
continue;
}
let Value::String(v) = value else {
continue;
};
let trimmed = v.trim();
if !trimmed.is_empty() {
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
}
}
}
if snippet.is_empty() {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&Value::Object(snippet))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenCode (JSON format)
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
// OpenCode uses a different config structure with npm, options, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("apiKey");
options.remove("baseURL");
}
// Keep npm and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Extract common config for OpenClaw (JSON format)
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
// For common config, we exclude provider-specific fields like apiKey
let mut config = settings.clone();
// Remove provider-specific fields
if let Some(obj) = config.as_object_mut() {
obj.remove("apiKey");
obj.remove("baseUrl");
// Keep api and models as they might be common
}
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
return Ok("{}".to_string());
}
serde_json::to_string_pretty(&config)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
@@ -844,6 +569,11 @@ impl ProviderService {
read_live_settings(app_type)
}
/// Patch Claude live settings directly (user-level preferences)
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
live::patch_claude_live(patch)
}
/// Get custom endpoints list (re-export)
pub fn get_custom_endpoints(
state: &AppState,
@@ -939,10 +669,6 @@ impl ProviderService {
.await
}
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
write_gemini_live(provider)
}
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
+2 -2
View File
@@ -8,7 +8,7 @@ use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
use crate::proxy::types::*;
use crate::services::provider::write_live_snapshot;
use crate::services::provider::write_live_partial;
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
@@ -1266,7 +1266,7 @@ impl ProxyService {
return Ok(false);
};
write_live_snapshot(app_type, provider)
write_live_partial(app_type, provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
Ok(true)
+20 -5
View File
@@ -37,12 +37,27 @@ pub struct SessionMessage {
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
let h1 = s.spawn(codex::scan_sessions);
let h2 = s.spawn(claude::scan_sessions);
let h3 = s.spawn(opencode::scan_sessions);
let h4 = s.spawn(openclaw::scan_sessions);
let h5 = s.spawn(gemini::scan_sessions);
(
h1.join().unwrap_or_default(),
h2.join().unwrap_or_default(),
h3.join().unwrap_or_default(),
h4.join().unwrap_or_default(),
h5.join().unwrap_or_default(),
)
});
let mut sessions = Vec::new();
sessions.extend(codex::scan_sessions());
sessions.extend(claude::scan_sessions());
sessions.extend(opencode::scan_sessions());
sessions.extend(openclaw::scan_sessions());
sessions.extend(gemini::scan_sessions());
sessions.extend(r1);
sessions.extend(r2);
sessions.extend(r3);
sessions.extend(r4);
sessions.extend(r5);
sessions.sort_by(|a, b| {
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
@@ -7,7 +7,9 @@ use serde_json::Value;
use crate::config::get_claude_config_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
};
const PROVIDER_ID: &str = "claude";
@@ -73,60 +75,61 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
return None;
}
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
// Extract metadata from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if session_id.is_none() {
session_id = value
.get("sessionId")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if project_dir.is_none() {
project_dir = value
.get("cwd")
.and_then(Value::as_str)
.map(|s| s.to_string());
}
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
if created_at.is_none() {
created_at = Some(ts);
}
last_active_at = Some(ts);
if created_at.is_none() {
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
}
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
}
// Extract last_active_at and summary from tail lines (reverse order)
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
let message = match value.get("message") {
Some(message) => message,
None => continue,
for line in tail.iter().rev() {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
let text = message.get("content").map(extract_text).unwrap_or_default();
if text.trim().is_empty() {
continue;
if last_active_at.is_none() {
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
if summary.is_none() {
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
continue;
}
if let Some(message) = value.get("message") {
let text = message.get("content").map(extract_text).unwrap_or_default();
if !text.trim().is_empty() {
summary = Some(text);
}
}
}
if last_active_at.is_some() && summary.is_some() {
break;
}
summary = Some(text);
}
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
@@ -1,6 +1,7 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use serde_json::Value;
@@ -8,10 +9,17 @@ use serde_json::Value;
use crate::codex_config::get_codex_config_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
};
const PROVIDER_ID: &str = "codex";
static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
.unwrap()
});
pub fn scan_sessions() -> Vec<SessionMeta> {
let root = get_codex_config_dir().join("sessions");
let mut files = Vec::new();
@@ -74,32 +82,21 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
// Extract metadata from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
if created_at.is_none() {
created_at = Some(ts);
}
last_active_at = Some(ts);
if created_at.is_none() {
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
if let Some(payload) = value.get("payload") {
if session_id.is_none() {
@@ -118,27 +115,34 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
created_at.get_or_insert(ts);
}
}
continue;
}
}
if value.get("type").and_then(Value::as_str) != Some("response_item") {
continue;
}
// Extract last_active_at and summary from tail lines (reverse order)
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
let payload = match value.get("payload") {
Some(payload) => payload,
None => continue,
for line in tail.iter().rev() {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if payload.get("type").and_then(Value::as_str) != Some("message") {
continue;
if last_active_at.is_none() {
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
let text = payload.get("content").map(extract_text).unwrap_or_default();
if text.trim().is_empty() {
continue;
if summary.is_none() && value.get("type").and_then(Value::as_str) == Some("response_item") {
if let Some(payload) = value.get("payload") {
if payload.get("type").and_then(Value::as_str) == Some("message") {
let text = payload.get("content").map(extract_text).unwrap_or_default();
if !text.trim().is_empty() {
summary = Some(text);
}
}
}
}
if last_active_at.is_some() && summary.is_some() {
break;
}
summary = Some(text);
}
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
@@ -166,10 +170,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
let file_name = path.file_name()?.to_string_lossy();
let re =
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
.ok()?;
re.find(&file_name).map(|mat| mat.as_str().to_string())
UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string())
}
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
@@ -7,7 +7,9 @@ use serde_json::Value;
use crate::openclaw_config::get_openclaw_dir;
use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
};
const PROVIDER_ID: &str = "openclaw";
@@ -114,30 +116,22 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option<String> = None;
let mut cwd: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut last_active_at: Option<i64> = None;
let mut summary: Option<String> = None;
for line in reader.lines() {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
let value: Value = match serde_json::from_str(&line) {
// Extract metadata and first message summary from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
if created_at.is_none() {
created_at = Some(ts);
}
last_active_at = Some(ts);
if created_at.is_none() {
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
}
let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
@@ -161,25 +155,28 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
continue;
}
if event_type != "message" {
continue;
// OpenClaw summary is the first message content
if event_type == "message" && summary.is_none() {
if let Some(message) = value.get("message") {
let text = message.get("content").map(extract_text).unwrap_or_default();
if !text.trim().is_empty() {
summary = Some(text);
}
}
}
}
// Extract first message content for summary
if summary.is_some() {
continue;
}
let message = match value.get("message") {
Some(msg) => msg,
None => continue,
// Extract last_active_at from tail lines (reverse order)
let mut last_active_at: Option<i64> = None;
for line in tail.iter().rev() {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(_) => continue,
};
let text = message.get("content").map(extract_text).unwrap_or_default();
if text.trim().is_empty() {
continue;
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
last_active_at = Some(ts);
break;
}
summary = Some(text);
}
// Fall back to filename as session ID
@@ -136,6 +136,7 @@ fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
.and_then(parse_timestamp_to_ms);
// Derive title from directory basename if no explicit title
let has_title = title.is_some();
let display_title = title.or_else(|| {
directory
.as_deref()
@@ -147,8 +148,12 @@ fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
let msg_dir = storage.join("message").join(&session_id);
let source_path = msg_dir.to_string_lossy().to_string();
// Get summary from first user message
let summary = get_first_user_summary(storage, &session_id);
// Skip expensive I/O if title already available from session JSON
let summary = if has_title {
display_title.clone()
} else {
get_first_user_summary(storage, &session_id)
};
Some(SessionMeta {
provider_id: PROVIDER_ID.to_string(),
@@ -1,6 +1,50 @@
use std::fs::File;
use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
use std::path::Path;
use chrono::{DateTime, FixedOffset};
use serde_json::Value;
/// Read the first `head_n` lines and last `tail_n` lines from a file.
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
pub fn read_head_tail_lines(
path: &Path,
head_n: usize,
tail_n: usize,
) -> io::Result<(Vec<String>, Vec<String>)> {
let file = File::open(path)?;
let file_len = file.metadata()?.len();
// For small files, read all lines once and split
if file_len < 16_384 {
let reader = BufReader::new(file);
let all: Vec<String> = reader.lines().map_while(Result::ok).collect();
let head = all.iter().take(head_n).cloned().collect();
let skip = all.len().saturating_sub(tail_n);
let tail = all.into_iter().skip(skip).collect();
return Ok((head, tail));
}
// Read head lines from the beginning
let reader = BufReader::new(file);
let head: Vec<String> = reader.lines().take(head_n).map_while(Result::ok).collect();
// Seek to last ~16 KB for tail lines
let seek_pos = file_len.saturating_sub(16_384);
let mut file2 = File::open(path)?;
file2.seek(SeekFrom::Start(seek_pos))?;
let tail_reader = BufReader::new(file2);
let all_tail: Vec<String> = tail_reader.lines().map_while(Result::ok).collect();
// Skip first partial line if we seeked into the middle of a line
let skip_first = if seek_pos > 0 { 1 } else { 0 };
let usable: Vec<String> = all_tail.into_iter().skip(skip_first).collect();
let skip = usable.len().saturating_sub(tail_n);
let tail = usable.into_iter().skip(skip).collect();
Ok((head, tail))
}
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
let raw = value.as_str()?;
DateTime::parse_from_rfc3339(raw)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.10.3",
"version": "3.11.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+1 -271
View File
@@ -2,10 +2,7 @@ use serde_json::json;
use std::fs;
use std::path::PathBuf;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
Provider, ProviderMeta,
};
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
#[path = "support.rs"]
mod support;
@@ -13,132 +10,6 @@ use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
};
#[test]
fn sync_claude_provider_writes_live_settings() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
let provider_config = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "test-key",
"ANTHROPIC_BASE_URL": "https://api.test"
},
"ui": {
"displayName": "Test Provider"
}
});
let provider = Provider::with_id(
"prov-1".to_string(),
"Test Claude".to_string(),
provider_config.clone(),
None,
);
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.providers.insert("prov-1".to_string(), provider);
manager.current = "prov-1".to_string();
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
let settings_path = get_claude_settings_path();
assert!(
settings_path.exists(),
"live settings should be written to {}",
settings_path.display()
);
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
assert_eq!(live_value, provider_config);
// 确认 SSOT 中的供应商也同步了最新内容
let updated = config
.get_manager(&AppType::Claude)
.and_then(|m| m.providers.get("prov-1"))
.expect("provider in config");
assert_eq!(updated.settings_config, provider_config);
// 额外确认写入位置位于测试 HOME 下
assert!(
settings_path.starts_with(home),
"settings path {settings_path:?} should reside under test HOME {home:?}"
);
}
#[test]
fn sync_codex_provider_writes_auth_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
let provider_config = json!({
"auth": {
"OPENAI_API_KEY": "codex-key"
},
"config": r#"base_url = "https://codex.test""#
});
let provider = Provider::with_id(
"codex-1".to_string(),
"Codex Test".to_string(),
provider_config.clone(),
None,
);
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert("codex-1".to_string(), provider);
manager.current = "codex-1".to_string();
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
let auth_path = cc_switch_lib::get_codex_auth_path();
let config_path = cc_switch_lib::get_codex_config_path();
assert!(
auth_path.exists(),
"auth.json should exist at {}",
auth_path.display()
);
assert!(
config_path.exists(),
"config.toml should exist at {}",
config_path.display()
);
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
assert_eq!(
auth_value,
provider_config.get("auth").cloned().expect("auth object")
);
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
// 验证基础配置正确写入
assert!(
toml_text.contains("base_url"),
"config.toml should contain base_url from provider config"
);
// 当前供应商应同步最新 config 文本
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
let synced = manager.providers.get("codex-1").expect("codex provider");
let synced_cfg = synced
.settings_config
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert_eq!(synced_cfg, toml_text);
}
#[test]
fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -338,46 +209,6 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
}
}
#[test]
fn sync_codex_provider_missing_auth_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let mut config = MultiAppConfig::default();
let provider = Provider::with_id(
"codex-missing-auth".to_string(),
"No Auth".to_string(),
json!({
"config": "model = \"test\""
}),
None,
);
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.providers.insert(provider.id.clone(), provider);
manager.current = "codex-missing-auth".to_string();
let err = ConfigService::sync_current_providers_to_live(&mut config)
.expect_err("sync should fail when auth missing");
match err {
cc_switch_lib::AppError::Config(msg) => {
assert!(msg.contains("auth"), "error message should mention auth");
}
other => panic!("unexpected error variant: {other:?}"),
}
// 确认未产生任何 live 配置文件
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"auth.json should not be created on failure"
);
assert!(
!cc_switch_lib::get_codex_config_path().exists(),
"config.toml should not be created on failure"
);
}
#[test]
fn write_codex_live_atomic_persists_auth_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -816,107 +647,6 @@ fn create_backup_retains_only_latest_entries() {
);
}
#[test]
fn sync_gemini_packycode_sets_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "packy-1".to_string();
manager.providers.insert(
"packy-1".to_string(),
Provider::with_id(
"packy-1".to_string(),
"PackyCode".to_string(),
json!({
"env": {
"GEMINI_API_KEY": "pk-key",
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
}
}),
Some("https://www.packyapi.com".to_string()),
),
);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing gemini live should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
);
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("gemini-api-key"),
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
);
}
#[test]
fn sync_gemini_google_official_sets_oauth_security() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Gemini)
.expect("gemini manager");
manager.current = "google-official".to_string();
let mut provider = Provider::with_id(
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
}),
Some("https://ai.google.dev".to_string()),
);
provider.meta = Some(ProviderMeta {
partner_promotion_key: Some("google-official".to_string()),
..ProviderMeta::default()
});
manager
.providers
.insert("google-official".to_string(), provider);
}
ConfigService::sync_current_providers_to_live(&mut config)
.expect("syncing google official gemini should succeed");
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
"Gemini settings should exist at {}",
gemini_settings.display()
);
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings should record oauth-personal for Google Official"
);
}
#[test]
fn export_sql_writes_to_target_path() {
let _guard = test_mutex().lock().expect("acquire test mutex");
+23 -12
View File
@@ -100,9 +100,12 @@ command = "say"
);
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
// With partial merge, only key fields (model, provider, model_providers) are
// merged into config.toml. The existing MCP section should be preserved.
// MCP sync from DB is handled separately (at startup or explicit sync).
assert!(
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
config_text.contains("mcp_servers.legacy"),
"config.toml should preserve existing MCP servers after partial merge"
);
let current_id = app_state
@@ -126,12 +129,9 @@ command = "say"
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
// 供应商配置应该包含在 live 文件中
// 注意:live 文件还会包含 MCP 同步后的内容
assert!(
config_text.contains("mcp_servers.latest"),
"live file should contain provider's original config"
);
// With partial merge, only key fields (model_provider, model, model_providers)
// are written to the live file. MCP servers are synced separately.
// The provider's stored config should still contain mcp_servers.latest.
assert!(
new_config_text.contains("mcp_servers.latest"),
"provider snapshot should contain provider's original config"
@@ -268,11 +268,22 @@ fn switch_provider_updates_claude_live_and_state() {
let legacy_provider = providers
.get("old-provider")
.expect("legacy provider still exists");
// 回填机制:切换前会将 live 配置回填到当前供应商
// 这保护了用户在 live 文件中的手动修改
// Backfill mechanism: before switching, the live config's key fields are
// backfilled to the current provider. With partial merge, only key fields
// (auth, model, endpoint) are extracted — non-key fields like workspace
// are NOT included in the backfill.
assert_eq!(
legacy_provider.settings_config, legacy_live,
"previous provider should be backfilled with live config"
legacy_provider
.settings_config
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("legacy-key"),
"previous provider should be backfilled with live auth key"
);
assert!(
legacy_provider.settings_config.get("workspace").is_none(),
"backfill should NOT include non-key fields like workspace"
);
let new_provider = providers.get("new-provider").expect("new provider exists");
+17 -9
View File
@@ -112,9 +112,12 @@ command = "say"
let config_text =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
// With partial merge, only key fields (model, provider, model_providers) are
// merged into config.toml. The existing MCP section should be preserved.
// MCP sync from DB is handled separately (at startup or explicit sync).
assert!(
config_text.contains("mcp_servers.echo-server"),
"config.toml should contain synced MCP servers"
config_text.contains("mcp_servers.legacy"),
"config.toml should preserve existing MCP servers after partial merge"
);
let current_id = state
@@ -143,11 +146,6 @@ command = "say"
new_config_text.contains("mcp_servers.latest"),
"provider config should contain original MCP servers"
);
// live 文件额外包含同步的 MCP 服务器
assert!(
config_text.contains("mcp_servers.echo-server"),
"live config should include synced MCP servers"
);
let legacy = providers
.get("old-provider")
@@ -414,9 +412,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
let legacy_provider = providers
.get("old-provider")
.expect("legacy provider still exists");
// With partial merge backfill, only key fields are extracted from live config
assert_eq!(
legacy_provider.settings_config, legacy_live,
"previous provider should receive backfilled live config"
legacy_provider
.settings_config
.get("env")
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
.and_then(|key| key.as_str()),
Some("legacy-key"),
"previous provider should receive backfilled auth key"
);
assert!(
legacy_provider.settings_config.get("workspace").is_none(),
"backfill should NOT include non-key fields like workspace"
);
}
-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);
@@ -192,6 +192,7 @@ export function EditProviderDialog({
onCancel={() => onOpenChange(false)}
initialData={initialData}
showButtons={false}
isCurrent={liveSettings !== null}
/>
</FullScreenPanel>
);
+1 -7
View File
@@ -24,7 +24,6 @@ interface ProviderActionsProps {
isTesting?: boolean;
isProxyTakeover?: boolean;
isOmo?: boolean;
isLastOmo?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
@@ -49,7 +48,6 @@ export function ProviderActions({
isTesting: _isTesting, // Hidden: stream check feature disabled
isProxyTakeover = false,
isOmo = false,
isLastOmo = false,
onSwitch,
onEdit,
onDuplicate,
@@ -192,11 +190,7 @@ export function ProviderActions({
const buttonState = getMainButtonState();
const canDelete = isOmo
? !(isLastOmo && isCurrent)
: isAdditiveMode
? true
: !isCurrent;
const canDelete = isOmo || isAdditiveMode ? true : !isCurrent;
return (
<div className="flex items-center gap-1.5">
@@ -28,9 +28,7 @@ interface ProviderCardProps {
appId: AppId;
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
isOmo?: boolean;
isLastOmo?: boolean;
isOmoSlim?: boolean;
isLastOmoSlim?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -94,9 +92,7 @@ export function ProviderCard({
appId,
isInConfig = true,
isOmo = false,
isLastOmo = false,
isOmoSlim = false,
isLastOmoSlim = false,
onSwitch,
onEdit,
onDelete,
@@ -125,7 +121,6 @@ export function ProviderCard({
// OMO and OMO Slim share the same card behavior
const isAnyOmo = isOmo || isOmoSlim;
const isLastAnyOmo = isOmo ? isLastOmo : isLastOmoSlim;
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
const { data: health } = useProviderHealth(provider.id, appId);
@@ -390,7 +385,6 @@ export function ProviderCard({
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
isOmo={isAnyOmo}
isLastOmo={isLastAnyOmo}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
-18
View File
@@ -36,9 +36,7 @@ import {
} from "@/lib/query/failover";
import {
useCurrentOmoProviderId,
useOmoProviderCount,
useCurrentOmoSlimProviderId,
useOmoSlimProviderCount,
} from "@/lib/query/omo";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
@@ -142,9 +140,7 @@ export function ProviderList({
const isOpenCode = appId === "opencode";
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);
const { data: omoSlimProviderCount } = useOmoSlimProviderCount(isOpenCode);
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
@@ -293,15 +289,7 @@ export function ProviderList({
appId={appId}
isInConfig={isProviderInConfig(provider.id)}
isOmo={isOmo}
isLastOmo={
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
}
isOmoSlim={isOmoSlim}
isLastOmoSlim={
isOmoSlim &&
(omoSlimProviderCount ?? 0) <= 1 &&
isOmoSlimCurrent
}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
@@ -420,9 +408,7 @@ interface SortableProviderCardProps {
appId: AppId;
isInConfig: boolean;
isOmo: boolean;
isLastOmo: boolean;
isOmoSlim: boolean;
isLastOmoSlim: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -453,9 +439,7 @@ function SortableProviderCard({
appId,
isInConfig,
isOmo,
isLastOmo,
isOmoSlim,
isLastOmoSlim,
onSwitch,
onEdit,
onDelete,
@@ -500,9 +484,7 @@ function SortableProviderCard({
appId={appId}
isInConfig={isInConfig}
isOmo={isOmo}
isLastOmo={isLastOmo}
isOmoSlim={isOmoSlim}
isLastOmoSlim={isLastOmoSlim}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
@@ -10,7 +10,11 @@ import {
} from "@/components/ui/select";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
import type {
ProviderCategory,
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
interface EndpointCandidate {
@@ -68,6 +72,10 @@ interface ClaudeFormFieldsProps {
// API Format (for third-party providers that use OpenAI Chat Completions format)
apiFormat: ClaudeApiFormat;
onApiFormatChange: (format: ClaudeApiFormat) => void;
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
apiKeyField: ClaudeApiKeyField;
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
}
export function ClaudeFormFields({
@@ -102,6 +110,8 @@ export function ClaudeFormFields({
speedTestEndpoints,
apiFormat,
onApiFormatChange,
apiKeyField,
onApiKeyFieldChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -188,8 +198,8 @@ export function ClaudeFormFields({
/>
)}
{/* API 格式选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
{/* API 格式选择(仅非官方、非云服务商显示) */}
{shouldShowModelSelector && category !== "cloud_provider" && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
@@ -219,6 +229,41 @@ export function ClaudeFormFields({
</div>
)}
{/* 认证字段选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel htmlFor="apiKeyField">
{t("providerForm.authField", { defaultValue: "认证字段" })}
</FormLabel>
<Select
value={apiKeyField}
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
>
<SelectTrigger id="apiKeyField" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
{t("providerForm.authFieldAuthToken", {
defaultValue: "Auth Token (默认)",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("providerForm.authFieldApiKey", {
defaultValue: "API Key",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.authFieldHint", {
defaultValue:
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
})}
</p>
</div>
)}
{/* 模型选择器 */}
{shouldShowModelSelector && (
<div className="space-y-3">
@@ -0,0 +1,134 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import { Checkbox } from "@/components/ui/checkbox";
type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates";
interface ClaudeQuickTogglesProps {
/** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */
onPatchApplied?: (patch: Record<string, unknown>) => void;
}
const defaultStates: Record<ToggleKey, boolean> = {
hideAttribution: false,
alwaysThinking: false,
enableTeammates: false,
};
function deriveStates(
cfg: Record<string, unknown>,
): Record<ToggleKey, boolean> {
const env = cfg?.env as Record<string, unknown> | undefined;
const attr = cfg?.attribution as Record<string, unknown> | undefined;
return {
hideAttribution: attr?.commit === "" && attr?.pr === "",
alwaysThinking: cfg?.alwaysThinkingEnabled === true,
enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1",
};
}
/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */
function jsonMergePatch(
target: Record<string, unknown>,
patch: Record<string, unknown>,
) {
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === undefined) {
delete target[key];
} else if (typeof value === "object" && !Array.isArray(value)) {
if (
typeof target[key] !== "object" ||
target[key] === null ||
Array.isArray(target[key])
) {
target[key] = {};
}
jsonMergePatch(
target[key] as Record<string, unknown>,
value as Record<string, unknown>,
);
if (Object.keys(target[key] as Record<string, unknown>).length === 0) {
delete target[key];
}
} else {
target[key] = value;
}
}
}
export { jsonMergePatch };
export function ClaudeQuickToggles({
onPatchApplied,
}: ClaudeQuickTogglesProps) {
const { t } = useTranslation();
const [states, setStates] = useState(defaultStates);
const readLive = useCallback(async () => {
try {
const cfg = await invoke<Record<string, unknown>>(
"read_live_provider_settings",
{ app: "claude" },
);
setStates(deriveStates(cfg));
} catch {
// Live file missing or unreadable — show all unchecked
}
}, []);
useEffect(() => {
readLive();
}, [readLive]);
const toggle = useCallback(
async (key: ToggleKey) => {
let patch: Record<string, unknown>;
if (key === "hideAttribution") {
patch = states.hideAttribution
? { attribution: null }
: { attribution: { commit: "", pr: "" } };
} else if (key === "alwaysThinking") {
patch = states.alwaysThinking
? { alwaysThinkingEnabled: null }
: { alwaysThinkingEnabled: true };
} else {
patch = states.enableTeammates
? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } }
: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } };
}
// Optimistic update
setStates((prev) => ({ ...prev, [key]: !prev[key] }));
try {
await invoke("patch_claude_live_settings", { patch });
onPatchApplied?.(patch);
} catch {
// Revert on failure
readLive();
}
},
[states, readLive, onPatchApplied],
);
return (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{(
[
["hideAttribution", "claudeConfig.hideAttribution"],
["alwaysThinking", "claudeConfig.alwaysThinking"],
["enableTeammates", "claudeConfig.enableTeammates"],
] as const
).map(([key, i18nKey]) => (
<label
key={key}
className="flex items-center gap-1.5 text-sm cursor-pointer"
>
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
{t(i18nKey)}
</label>
))}
</div>
);
}
@@ -1,107 +0,0 @@
import React, { useEffect, useState } from "react";
import { Save, Download, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
interface CodexCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
onChange: (value: string) => void;
error?: string;
onExtract?: () => void;
isExtracting?: boolean;
}
/**
* CodexCommonConfigModal - Common Codex configuration editor modal
* Allows editing of common TOML configuration shared across providers
*/
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
isOpen,
onClose,
value,
onChange,
error,
onExtract,
isExtracting,
}) => {
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();
}, []);
return (
<FullScreenPanel
isOpen={isOpen}
title={t("codexConfig.editCommonConfigTitle")}
onClose={onClose}
footer={
<>
{onExtract && (
<Button
type="button"
variant="outline"
onClick={onExtract}
disabled={isExtracting}
className="gap-2"
>
{isExtracting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t("codexConfig.extractFromCurrent", {
defaultValue: "从编辑内容提取",
})}
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={onClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("codexConfig.commonConfigHint")}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
darkMode={isDarkMode}
rows={16}
showValidation={false}
language="javascript"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
);
};
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import React from "react";
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
interface CodexConfigEditorProps {
authValue: string;
@@ -13,23 +12,9 @@ interface CodexConfigEditorProps {
onAuthBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
authError: string;
configError: string; // config.toml 错误提示
onExtract?: () => void;
isExtracting?: boolean;
configError: string;
}
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
@@ -38,25 +23,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onAuthChange,
onConfigChange,
onAuthBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
authError,
configError,
onExtract,
isExtracting,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// Auto-open common config modal if there's an error
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
return (
<div className="space-y-6">
{/* Auth JSON Section */}
@@ -71,23 +40,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
<CodexConfigSection
value={configValue}
onChange={onConfigChange}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={onCommonConfigToggle}
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
configError={configError}
/>
{/* Common Config Modal */}
<CodexCommonConfigModal
isOpen={isCommonConfigModalOpen}
onClose={() => setIsCommonConfigModalOpen(false)}
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
error={commonConfigError}
onExtract={onExtract}
isExtracting={isExtracting}
/>
</div>
);
};
@@ -78,10 +78,6 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
interface CodexConfigSectionProps {
value: string;
onChange: (value: string) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
onEditCommonConfig: () => void;
commonConfigError?: string;
configError?: string;
}
@@ -91,10 +87,6 @@ interface CodexConfigSectionProps {
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
onEditCommonConfig,
commonConfigError,
configError,
}) => {
const { t } = useTranslation();
@@ -117,40 +109,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.configToml")}
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
{t("codexConfig.writeCommonConfig")}
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditCommonConfig}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
{t("codexConfig.editCommonConfig")}
</button>
</div>
{commonConfigError && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.configToml")}
</label>
<JsonEditor
value={value}
@@ -1,280 +0,0 @@
import { useTranslation } from "react-i18next";
import { useEffect, useState, useCallback, useMemo } 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 JsonEditor from "@/components/JsonEditor";
interface CommonConfigEditorProps {
value: string;
onChange: (value: string) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
onEditClick: () => void;
isModalOpen: boolean;
onModalClose: () => void;
onExtract?: () => void;
isExtracting?: boolean;
}
export function CommonConfigEditor({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
onEditClick,
isModalOpen,
onModalClose,
onExtract,
isExtracting,
}: CommonConfigEditorProps) {
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();
}, []);
// Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync
// (parent uses form.getValues which doesn't trigger re-renders)
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleLocalChange = useCallback(
(newValue: string) => {
setLocalValue(newValue);
onChange(newValue);
},
[onChange],
);
const toggleStates = useMemo(() => {
try {
const config = JSON.parse(localValue);
return {
hideAttribution:
config?.attribution?.commit === "" && config?.attribution?.pr === "",
alwaysThinking: config?.alwaysThinkingEnabled === true,
teammates:
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
};
} catch {
return {
hideAttribution: false,
alwaysThinking: false,
teammates: false,
};
}
}, [localValue]);
const handleToggle = useCallback(
(toggleKey: string, checked: boolean) => {
try {
const config = JSON.parse(localValue || "{}");
switch (toggleKey) {
case "hideAttribution":
if (checked) {
config.attribution = { commit: "", pr: "" };
} else {
delete config.attribution;
}
break;
case "alwaysThinking":
if (checked) {
config.alwaysThinkingEnabled = true;
} else {
delete config.alwaysThinkingEnabled;
}
break;
case "teammates":
if (!config.env) config.env = {};
if (checked) {
config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
} else {
delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
if (Object.keys(config.env).length === 0) delete config.env;
}
break;
}
handleLocalChange(JSON.stringify(config, null, 2));
} catch {
// Don't modify if JSON is invalid
}
},
[localValue, handleLocalChange],
);
return (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
id="useCommonConfig"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>
{t("claudeConfig.writeCommonConfig", {
defaultValue: "写入通用配置",
})}
</span>
</label>
</div>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{t("claudeConfig.editCommonConfig", {
defaultValue: "编辑通用配置",
})}
</button>
</div>
{commonConfigError && !isModalOpen && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.hideAttribution}
onChange={(e) =>
handleToggle("hideAttribution", e.target.checked)
}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.hideAttribution")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.alwaysThinking}
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.alwaysThinking")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.teammates}
onChange={(e) => handleToggle("teammates", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.enableTeammates")}</span>
</label>
</div>
<JsonEditor
value={localValue}
onChange={handleLocalChange}
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"
/>
</div>
<FullScreenPanel
isOpen={isModalOpen}
title={t("claudeConfig.editCommonConfigTitle", {
defaultValue: "编辑通用配置片段",
})}
onClose={onModalClose}
footer={
<>
{onExtract && (
<Button
type="button"
variant="outline"
onClick={onExtract}
disabled={isExtracting}
className="gap-2"
>
{isExtracting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t("claudeConfig.extractFromCurrent", {
defaultValue: "从编辑内容提取",
})}
</Button>
)}
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={onModalClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
}
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</FullScreenPanel>
</>
);
}
@@ -1,106 +0,0 @@
import React, { useEffect, useState } from "react";
import { Save, Download, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
interface GeminiCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
onChange: (value: string) => void;
error?: string;
onExtract?: () => void;
isExtracting?: boolean;
}
/**
* GeminiCommonConfigModal - Common Gemini configuration editor modal
* Allows editing of common env snippet shared across Gemini providers
*/
export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
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();
}, []);
return (
<FullScreenPanel
isOpen={isOpen}
title={t("geminiConfig.editCommonConfigTitle", {
defaultValue: "编辑 Gemini 通用配置片段",
})}
onClose={onClose}
footer={
<>
{onExtract && (
<Button
type="button"
variant="outline"
onClick={onExtract}
disabled={isExtracting}
className="gap-2"
>
{isExtracting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t("geminiConfig.extractFromCurrent", {
defaultValue: "从编辑内容提取",
})}
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={onClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY",
})}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"GEMINI_MODEL": "gemini-3-pro-preview"
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
);
};
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import React from "react";
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
interface GeminiConfigEditorProps {
envValue: string;
@@ -8,15 +7,8 @@ interface GeminiConfigEditorProps {
onEnvChange: (value: string) => void;
onConfigChange: (value: string) => void;
onEnvBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
envError: string;
configError: string;
onExtract?: () => void;
isExtracting?: boolean;
}
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
@@ -25,25 +17,9 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onEnvChange,
onConfigChange,
onEnvBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
envError,
configError,
onExtract,
isExtracting,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// Auto-open common config modal if there's an error
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
}, [commonConfigError, isCommonConfigModalOpen]);
return (
<div className="space-y-6">
{/* Env Section */}
@@ -52,10 +28,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onChange={onEnvChange}
onBlur={onEnvBlur}
error={envError}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={onCommonConfigToggle}
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
/>
{/* Config JSON Section */}
@@ -64,17 +36,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
onChange={onConfigChange}
configError={configError}
/>
{/* Common Config Modal */}
<GeminiCommonConfigModal
isOpen={isCommonConfigModalOpen}
onClose={() => setIsCommonConfigModalOpen(false)}
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
error={commonConfigError}
onExtract={onExtract}
isExtracting={isExtracting}
/>
</div>
);
};
@@ -7,10 +7,6 @@ interface GeminiEnvSectionProps {
onChange: (value: string) => void;
onBlur?: () => void;
error?: string;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
onEditCommonConfig: () => void;
commonConfigError?: string;
}
/**
@@ -21,10 +17,6 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
onChange,
onBlur,
error,
useCommonConfig,
onCommonConfigToggle,
onEditCommonConfig,
commonConfigError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -53,44 +45,12 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="geminiEnv"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
{t("geminiConfig.writeCommonConfig", {
defaultValue: "写入通用配置",
})}
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditCommonConfig}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
{t("geminiConfig.editCommonConfig", {
defaultValue: "编辑通用配置",
})}
</button>
</div>
{commonConfigError && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<label
htmlFor="geminiEnv"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
<JsonEditor
value={value}
@@ -1,164 +0,0 @@
import { useTranslation } from "react-i18next";
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, FolderInput, Loader2 } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
import {
OmoGlobalConfigFields,
type OmoGlobalConfigFieldsRef,
} from "./OmoGlobalConfigFields";
import type { OmoGlobalConfig } from "@/types/omo";
interface OmoCommonConfigEditorProps {
previewValue: string;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
isModalOpen: boolean;
onEditClick: () => void;
onModalClose: () => void;
onSave: () => Promise<void>;
isSaving: boolean;
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
fieldsKey: number;
isSlim?: boolean;
}
export function OmoCommonConfigEditor({
previewValue,
useCommonConfig,
onCommonConfigToggle,
isModalOpen,
onEditClick,
onModalClose,
onSave,
isSaving,
onGlobalConfigStateChange,
globalConfigRef,
fieldsKey,
isSlim = false,
}: OmoCommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const [isImporting, setIsImporting] = useState(false);
useEffect(() => {
const syncDarkMode = () =>
setIsDarkMode(document.documentElement.classList.contains("dark"));
syncDarkMode();
const observer = new MutationObserver(syncDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleImportLocal = async () => {
if (!globalConfigRef.current) return;
setIsImporting(true);
try {
await globalConfigRef.current.importFromLocal();
} finally {
setIsImporting(false);
}
};
return (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("provider.configJson")}</Label>
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>
{t("omo.writeCommonConfig", {
defaultValue: "Write to common config",
})}
</span>
</label>
</div>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditClick}
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
{t("omo.editCommonConfig", { defaultValue: "Edit common config" })}
</button>
</div>
<JsonEditor
value={previewValue}
onChange={() => {}}
darkMode={isDarkMode}
rows={14}
showValidation={false}
language="json"
/>
</div>
<FullScreenPanel
isOpen={isModalOpen}
title={t("omo.editCommonConfigTitle", {
defaultValue: "Edit OMO Common Config",
})}
onClose={onModalClose}
footer={
<>
<Button
type="button"
variant="outline"
onClick={handleImportLocal}
disabled={isImporting}
className="gap-2"
>
{isImporting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<FolderInput className="w-4 h-4" />
)}
{t("common.import", { defaultValue: "Import" })}
</Button>
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={onSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("omo.commonConfigHint", {
defaultValue:
"OMO common config will be merged into all OMO configs that enable it",
})}
</p>
<OmoGlobalConfigFields
key={fieldsKey}
ref={globalConfigRef as React.Ref<OmoGlobalConfigFieldsRef>}
onStateChange={onGlobalConfigStateChange}
hideSaveButtons
isSlim={isSlim}
/>
</div>
</FullScreenPanel>
</>
);
}
@@ -722,14 +722,23 @@ export function OmoFormFields({
return;
}
let filledCount = 0;
let alreadySetCount = 0;
let unmatchedCount = 0;
const updatedAgents = { ...agents };
for (const agentDef of builtinAgentDefs) {
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
if (!recommendedValue) {
unmatchedCount++;
} else if (updatedAgents[agentDef.key]?.model) {
alreadySetCount++;
} else {
updatedAgents[agentDef.key] = {
...updatedAgents[agentDef.key],
model: recommendedValue,
};
filledCount++;
}
}
onAgentsChange(updatedAgents);
@@ -738,15 +747,50 @@ export function OmoFormFields({
const updatedCategories = { ...categories };
for (const catDef of OMO_BUILTIN_CATEGORIES) {
const recommendedValue = resolveRecommendedModel(catDef.recommended);
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
if (!recommendedValue) {
unmatchedCount++;
} else if (updatedCategories[catDef.key]?.model) {
alreadySetCount++;
} else {
updatedCategories[catDef.key] = {
...updatedCategories[catDef.key],
model: recommendedValue,
};
filledCount++;
}
}
onCategoriesChange(updatedCategories);
}
if (filledCount > 0 && unmatchedCount === 0) {
toast.success(
t("omo.fillRecommendedSuccess", {
defaultValue: "Filled {{count}} recommended models",
count: filledCount,
}),
);
} else if (filledCount > 0 && unmatchedCount > 0) {
toast.success(
t("omo.fillRecommendedPartial", {
defaultValue:
"Filled {{filled}} recommended models, {{unmatched}} unmatched",
filled: filledCount,
unmatched: unmatchedCount,
}),
);
} else if (alreadySetCount > 0 && unmatchedCount === 0) {
toast.info(
t("omo.fillRecommendedAllSet", {
defaultValue: "All slots already have models configured",
}),
);
} else {
toast.warning(
t("omo.fillRecommendedNoMatch", {
defaultValue: "Recommended models not found in configured providers",
}),
);
}
};
const configuredAgentCount = Object.keys(agents).length;
@@ -1,773 +0,0 @@
import {
useState,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Save,
Loader2,
X,
FolderInput,
RotateCcw,
ChevronsUpDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { OmoGlobalConfig } from "@/types/omo";
import {
OMO_DISABLEABLE_AGENTS,
OMO_DISABLEABLE_MCPS,
OMO_DISABLEABLE_HOOKS,
OMO_DISABLEABLE_SKILLS,
OMO_DEFAULT_SCHEMA_URL,
OMO_SISYPHUS_AGENT_PLACEHOLDER,
OMO_LSP_PLACEHOLDER,
OMO_EXPERIMENTAL_PLACEHOLDER,
OMO_BACKGROUND_TASK_PLACEHOLDER,
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
OMO_CLAUDE_CODE_PLACEHOLDER,
OMO_SLIM_DISABLEABLE_AGENTS,
OMO_SLIM_DISABLEABLE_MCPS,
OMO_SLIM_DISABLEABLE_HOOKS,
OMO_SLIM_DEFAULT_SCHEMA_URL,
} from "@/types/omo";
import {
useOmoGlobalConfig,
useSaveOmoGlobalConfig,
useReadOmoLocalFile,
useOmoSlimGlobalConfig,
useSaveOmoSlimGlobalConfig,
useReadOmoSlimLocalFile,
} from "@/lib/query/omo";
interface PresetOption {
readonly value: string;
readonly label: string;
}
export interface OmoGlobalConfigFieldsRef {
buildCurrentConfig: () => OmoGlobalConfig;
buildCurrentConfigStrict: () => OmoGlobalConfig;
importFromLocal: () => Promise<void>;
}
interface OmoGlobalConfigFieldsProps {
onStateChange?: (config: OmoGlobalConfig) => void;
hideSaveButtons?: boolean;
isSlim?: boolean;
}
type OmoAdvancedFieldKey =
| "lspStr"
| "experimentalStr"
| "backgroundTaskStr"
| "browserStr"
| "claudeCodeStr";
const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{
key: OmoAdvancedFieldKey;
labelKey: string;
defaultLabel: string;
placeholder: string;
minHeight: string;
}> = [
{
key: "lspStr",
labelKey: "omo.advancedLsp",
defaultLabel: "LSP Config",
placeholder: OMO_LSP_PLACEHOLDER,
minHeight: "200px",
},
{
key: "experimentalStr",
labelKey: "omo.advancedExperimental",
defaultLabel: "Experimental Features",
placeholder: OMO_EXPERIMENTAL_PLACEHOLDER,
minHeight: "120px",
},
{
key: "backgroundTaskStr",
labelKey: "omo.advancedBackgroundTask",
defaultLabel: "Background Tasks",
placeholder: OMO_BACKGROUND_TASK_PLACEHOLDER,
minHeight: "250px",
},
{
key: "browserStr",
labelKey: "omo.advancedBrowserAutomation",
defaultLabel: "Browser Automation",
placeholder: OMO_BROWSER_AUTOMATION_PLACEHOLDER,
minHeight: "80px",
},
{
key: "claudeCodeStr",
labelKey: "omo.advancedClaudeCode",
defaultLabel: "Claude Code",
placeholder: OMO_CLAUDE_CODE_PLACEHOLDER,
minHeight: "180px",
},
];
const OMO_SLIM_ADVANCED_KEYS: ReadonlySet<OmoAdvancedFieldKey> = new Set([
"lspStr",
"experimentalStr",
]);
function TagListEditor({
label,
values,
onChange,
placeholder,
presets,
}: {
label: string;
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
presets?: readonly PresetOption[];
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const toggleValue = (v: string) => {
if (values.includes(v)) {
onChange(values.filter((x) => x !== v));
} else {
onChange([...values, v]);
}
};
const customValue = search.trim();
const canAddCustom = customValue.length > 0 && !values.includes(customValue);
const triggerText =
values.length === 0
? placeholder || t("omo.selectPlaceholder", { defaultValue: "Select..." })
: values.length === 1
? values[0]
: `${values[0]} +${values.length - 1}`;
const availablePresets = presets?.filter(
(p) =>
!search.trim() ||
p.label.toLowerCase().includes(search.toLowerCase()) ||
p.value.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-sm">{label}</Label>
{values.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-xs text-muted-foreground"
onClick={() => onChange([])}
>
{t("omo.clear", { defaultValue: "Clear" })}
</Button>
)}
</div>
{values.length > 0 && (
<div className="flex flex-wrap gap-1">
{values.map((v, i) => (
<Badge
key={`${v}-${i}`}
variant="secondary"
className="text-xs gap-1"
>
{v}
<button
type="button"
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center justify-between w-full h-8 px-3 rounded-md border border-input bg-background text-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
open && "ring-2 ring-ring",
)}
aria-expanded={open}
>
<span
className={cn(
"truncate",
values.length > 0 ? "text-foreground" : "text-muted-foreground",
)}
>
{triggerText}
</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[var(--radix-dropdown-menu-trigger-width)] p-0 z-[120]"
>
<div className="p-1.5 border-b border-border/30">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" && canAddCustom) {
e.preventDefault();
onChange([...values, customValue]);
setSearch("");
}
}}
placeholder={
placeholder ||
t("omo.searchOrType", {
defaultValue: "Search or type custom value...",
})
}
className="h-7 text-sm"
autoFocus
/>
</div>
{canAddCustom && (
<button
type="button"
className="w-full px-2.5 py-1.5 text-left text-sm border-b border-border/30 hover:bg-accent"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange([...values, customValue]);
setSearch("");
}}
>
+ {customValue}
</button>
)}
<div className="max-h-48 overflow-auto py-1">
{availablePresets && availablePresets.length > 0 ? (
availablePresets.map((p) => {
const checked = values.includes(p.value);
return (
<DropdownMenuCheckboxItem
key={p.value}
checked={checked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={() => toggleValue(p.value)}
className="text-sm"
>
{p.label}
</DropdownMenuCheckboxItem>
);
})
) : (
<div className="px-2.5 py-2 text-sm text-muted-foreground">
{t("omo.noMatches", { defaultValue: "No matches" })}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function JsonTextareaField({
label,
value,
onChange,
placeholder,
minHeight,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: string;
}) {
return (
<div className="space-y-1.5">
<Label className="text-sm">{label}</Label>
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || "{}"}
className="font-mono text-sm"
style={{ minHeight: minHeight || "100px" }}
/>
</div>
);
}
export const OmoGlobalConfigFields = forwardRef<
OmoGlobalConfigFieldsRef,
OmoGlobalConfigFieldsProps
>(function OmoGlobalConfigFields(
{ onStateChange, hideSaveButtons, isSlim = false },
ref,
) {
const { t } = useTranslation();
const { data: standardConfig } = useOmoGlobalConfig(!isSlim);
const { data: slimConfig } = useOmoSlimGlobalConfig(isSlim);
const config = isSlim ? slimConfig : standardConfig;
const standardSaveMutation = useSaveOmoGlobalConfig();
const slimSaveMutation = useSaveOmoSlimGlobalConfig();
const saveMutation = isSlim ? slimSaveMutation : standardSaveMutation;
const standardReadLocal = useReadOmoLocalFile();
const slimReadLocal = useReadOmoSlimLocalFile();
const defaultSchemaUrl = isSlim
? OMO_SLIM_DEFAULT_SCHEMA_URL
: OMO_DEFAULT_SCHEMA_URL;
const [schemaUrl, setSchemaUrl] = useState(defaultSchemaUrl);
const [sisyphusAgentStr, setSisyphusAgentStr] = useState("");
const [disabledAgents, setDisabledAgents] = useState<string[]>([]);
const [disabledMcps, setDisabledMcps] = useState<string[]>([]);
const [disabledHooks, setDisabledHooks] = useState<string[]>([]);
const [disabledSkills, setDisabledSkills] = useState<string[]>([]);
const [lspStr, setLspStr] = useState("");
const [experimentalStr, setExperimentalStr] = useState("");
const [backgroundTaskStr, setBackgroundTaskStr] = useState("");
const [browserStr, setBrowserStr] = useState("");
const [claudeCodeStr, setClaudeCodeStr] = useState("");
const [otherFieldsStr, setOtherFieldsStr] = useState("");
const [loaded, setLoaded] = useState(false);
const applyGlobalState = useCallback((global: OmoGlobalConfig) => {
setSchemaUrl(global.schemaUrl || defaultSchemaUrl);
setSisyphusAgentStr(
global.sisyphusAgent ? JSON.stringify(global.sisyphusAgent, null, 2) : "",
);
setDisabledAgents(global.disabledAgents || []);
setDisabledMcps(global.disabledMcps || []);
setDisabledHooks(global.disabledHooks || []);
setDisabledSkills(global.disabledSkills || []);
setLspStr(global.lsp ? JSON.stringify(global.lsp, null, 2) : "");
setExperimentalStr(
global.experimental ? JSON.stringify(global.experimental, null, 2) : "",
);
setBackgroundTaskStr(
global.backgroundTask
? JSON.stringify(global.backgroundTask, null, 2)
: "",
);
setBrowserStr(
global.browserAutomationEngine
? JSON.stringify(global.browserAutomationEngine, null, 2)
: "",
);
setClaudeCodeStr(
global.claudeCode ? JSON.stringify(global.claudeCode, null, 2) : "",
);
setOtherFieldsStr(
global.otherFields ? JSON.stringify(global.otherFields, null, 2) : "",
);
}, []);
useEffect(() => {
if (config && !loaded) {
applyGlobalState(config);
setLoaded(true);
}
}, [config, loaded, applyGlobalState]);
const parseJsonField = useCallback(
(
fieldName: string,
raw: string,
strict: boolean,
): Record<string, unknown> | undefined => {
if (!raw.trim()) return undefined;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
if (strict) {
throw new Error(
t("omo.jsonMustBeObject", {
field: fieldName,
defaultValue: "{{field}} must be a JSON object",
}),
);
}
return undefined;
}
return parsed as Record<string, unknown>;
} catch (error) {
if (strict) {
if (error instanceof Error) {
throw error;
}
throw new Error(
t("omo.jsonInvalid", {
field: fieldName,
defaultValue: "{{field}} contains invalid JSON",
}),
);
}
return undefined;
}
},
[t],
);
const buildCurrentConfigInternal = useCallback(
(strict: boolean): OmoGlobalConfig => {
return {
id: "global",
schemaUrl: schemaUrl || undefined,
sisyphusAgent: parseJsonField(
t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
}),
sisyphusAgentStr,
strict,
),
disabledAgents,
disabledMcps,
disabledHooks,
disabledSkills,
lsp: parseJsonField(
t("omo.advancedLsp", { defaultValue: "LSP" }),
lspStr,
strict,
),
experimental: parseJsonField(
t("omo.advancedExperimental", { defaultValue: "Experimental" }),
experimentalStr,
strict,
),
backgroundTask: parseJsonField(
t("omo.advancedBackgroundTask", {
defaultValue: "Background Task",
}),
backgroundTaskStr,
strict,
),
browserAutomationEngine: parseJsonField(
t("omo.advancedBrowserAutomation", {
defaultValue: "Browser Automation",
}),
browserStr,
strict,
),
claudeCode: parseJsonField(
t("omo.advancedClaudeCode", { defaultValue: "Claude Code" }),
claudeCodeStr,
strict,
),
otherFields: parseJsonField(
t("omo.otherFields", {
defaultValue: "Other Config",
}),
otherFieldsStr,
strict,
),
updatedAt: new Date().toISOString(),
};
},
[
schemaUrl,
sisyphusAgentStr,
disabledAgents,
disabledMcps,
disabledHooks,
disabledSkills,
lspStr,
experimentalStr,
backgroundTaskStr,
browserStr,
claudeCodeStr,
otherFieldsStr,
parseJsonField,
],
);
const buildCurrentConfig = useCallback(
() => buildCurrentConfigInternal(false),
[buildCurrentConfigInternal],
);
const buildCurrentConfigStrict = useCallback(
() => buildCurrentConfigInternal(true),
[buildCurrentConfigInternal],
);
useEffect(() => {
if (loaded && onStateChange) {
onStateChange(buildCurrentConfig());
}
}, [loaded, onStateChange, buildCurrentConfig]);
const handleSaveGlobal = useCallback(async () => {
try {
const result = buildCurrentConfigStrict();
await saveMutation.mutateAsync(result);
toast.success(
t("omo.globalConfigSaved", {
defaultValue: "Global config saved",
}),
);
} catch (err) {
toast.error(String(err));
}
}, [buildCurrentConfigStrict, saveMutation, t]);
const disabledCount =
disabledAgents.length +
disabledMcps.length +
disabledHooks.length +
disabledSkills.length;
const advancedFieldValues: Record<OmoAdvancedFieldKey, string> = {
lspStr,
experimentalStr,
backgroundTaskStr,
browserStr,
claudeCodeStr,
};
const advancedFieldSetters: Record<
OmoAdvancedFieldKey,
(value: string) => void
> = {
lspStr: setLspStr,
experimentalStr: setExperimentalStr,
backgroundTaskStr: setBackgroundTaskStr,
browserStr: setBrowserStr,
claudeCodeStr: setClaudeCodeStr,
};
const disabledEditorConfigs = [
{
key: "agents",
label: t("omo.disabledAgents", { defaultValue: "Agents" }),
values: disabledAgents,
onChange: setDisabledAgents,
placeholder: t("omo.disabledAgentsPlaceholder", {
defaultValue: "Disabled Agents",
}),
presets: isSlim ? OMO_SLIM_DISABLEABLE_AGENTS : OMO_DISABLEABLE_AGENTS,
},
{
key: "mcps",
label: t("omo.disabledMcps", { defaultValue: "MCPs" }),
values: disabledMcps,
onChange: setDisabledMcps,
placeholder: t("omo.disabledMcpsPlaceholder", {
defaultValue: "Disabled MCPs",
}),
presets: isSlim ? OMO_SLIM_DISABLEABLE_MCPS : OMO_DISABLEABLE_MCPS,
},
{
key: "hooks",
label: t("omo.disabledHooks", { defaultValue: "Hooks" }),
values: disabledHooks,
onChange: setDisabledHooks,
placeholder: t("omo.disabledHooksPlaceholder", {
defaultValue: "Disabled Hooks",
}),
presets: isSlim ? OMO_SLIM_DISABLEABLE_HOOKS : OMO_DISABLEABLE_HOOKS,
},
...(!isSlim
? [
{
key: "skills" as const,
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
values: disabledSkills,
onChange: setDisabledSkills,
placeholder: t("omo.disabledSkillsPlaceholder", {
defaultValue: "Disabled Skills",
}),
presets: OMO_DISABLEABLE_SKILLS,
},
]
: []),
];
const readLocalFile = isSlim ? slimReadLocal : standardReadLocal;
const handleImportGlobalFromLocal = useCallback(async () => {
try {
const data = await readLocalFile.mutateAsync();
applyGlobalState(data.global);
toast.success(
t("omo.importGlobalSuccess", {
defaultValue: "Imported global config from local file (unsaved)",
}),
);
} catch (err) {
toast.error(
t("omo.importGlobalFailed", {
error: String(err),
defaultValue: "Failed to read local file: {{error}}",
}),
);
}
}, [readLocalFile, applyGlobalState, t]);
useImperativeHandle(
ref,
() => ({
buildCurrentConfig,
buildCurrentConfigStrict,
importFromLocal: handleImportGlobalFromLocal,
}),
[buildCurrentConfig, buildCurrentConfigStrict, handleImportGlobalFromLocal],
);
return (
<div className="space-y-4">
{!hideSaveButtons && (
<div className="flex items-center justify-end gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={readLocalFile.isPending}
onClick={handleImportGlobalFromLocal}
>
{readLocalFile.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<FolderInput className="h-3.5 w-3.5 mr-1" />
)}
{t("omo.importLocal", { defaultValue: "Import Local" })}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={saveMutation.isPending}
onClick={handleSaveGlobal}
>
{saveMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1" />
)}
{t("omo.saveGlobalConfig", { defaultValue: "Save Global Config" })}
</Button>
</div>
)}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-sm">
{t("omo.schemaUrl", { defaultValue: "$schema" })}
</Label>
{schemaUrl !== OMO_DEFAULT_SCHEMA_URL && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-xs px-1.5"
onClick={() => setSchemaUrl(OMO_DEFAULT_SCHEMA_URL)}
>
<RotateCcw className="h-3 w-3 mr-0.5" />
{t("omo.resetDefault", { defaultValue: "Reset" })}
</Button>
)}
</div>
<Input
value={schemaUrl}
onChange={(e) => setSchemaUrl(e.target.value)}
placeholder={defaultSchemaUrl}
className="text-sm h-8"
/>
</div>
{!isSlim && (
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.sisyphusAgentConfig", {
defaultValue: "Sisyphus Agent",
})}
</Label>
<Textarea
value={sisyphusAgentStr}
onChange={(e) => setSisyphusAgentStr(e.target.value)}
placeholder={OMO_SISYPHUS_AGENT_PLACEHOLDER}
className="font-mono text-sm"
style={{ minHeight: "140px" }}
/>
</div>
)}
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-3">
<div className="flex items-center gap-2">
<Label className="text-sm font-semibold">
{t("omo.disabledItems", { defaultValue: "Disabled Items" })}
</Label>
{disabledCount > 0 && (
<Badge variant="secondary" className="text-xs h-5">
{disabledCount}
</Badge>
)}
</div>
{disabledEditorConfigs.map((editor) => (
<TagListEditor
key={editor.key}
label={editor.label}
values={editor.values}
onChange={editor.onChange}
placeholder={editor.placeholder}
presets={editor.presets}
/>
))}
</div>
<div className="rounded-md border border-border/40 bg-muted/10 p-2 space-y-2">
<Label className="text-sm font-semibold">
{t("omo.advanced", { defaultValue: "Advanced Settings" })}
</Label>
{OMO_ADVANCED_JSON_FIELDS.filter(
(field) => !isSlim || OMO_SLIM_ADVANCED_KEYS.has(field.key),
).map((field) => (
<JsonTextareaField
key={field.key}
label={t(field.labelKey, { defaultValue: field.defaultLabel })}
value={advancedFieldValues[field.key]}
onChange={advancedFieldSetters[field.key]}
placeholder={field.placeholder}
minHeight={field.minHeight}
/>
))}
<JsonTextareaField
label={t("omo.otherFields", {
defaultValue: "Other Config",
})}
value={otherFieldsStr}
onChange={setOtherFieldsStr}
/>
</div>
</div>
);
});
+142 -136
View File
@@ -14,6 +14,7 @@ import type {
ProviderTestConfig,
ProviderProxyConfig,
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import {
providerPresets,
@@ -39,13 +40,16 @@ import {
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
import {
applyTemplateValues,
hasApiKeyField,
} from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
import GeminiConfigEditor from "./GeminiConfigEditor";
import JsonEditor from "@/components/JsonEditor";
import { ClaudeQuickToggles, jsonMergePatch } from "./ClaudeQuickToggles";
import { Label } from "@/components/ui/label";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
@@ -53,7 +57,6 @@ import { ClaudeFormFields } from "./ClaudeFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor";
import { parseOmoOtherFieldsObject } from "@/types/omo";
import {
ProviderAdvancedConfig,
@@ -67,18 +70,14 @@ import {
useCodexConfigState,
useApiKeyLink,
useTemplateValues,
useCommonConfigSnippet,
useCodexCommonConfig,
useSpeedTestEndpoints,
useCodexTomlValidation,
useGeminiConfigState,
useGeminiCommonConfig,
useOmoModelSource,
useOpencodeFormState,
useOmoDraftState,
useOpenclawFormState,
} from "./hooks";
import { useOmoGlobalConfig, useOmoSlimGlobalConfig } from "@/lib/query/omo";
import {
CLAUDE_DEFAULT_CONFIG,
CODEX_DEFAULT_CONFIG,
@@ -117,6 +116,7 @@ interface ProviderFormProps {
iconColor?: string;
};
showButtons?: boolean;
isCurrent?: boolean;
}
export function ProviderForm({
@@ -129,6 +129,7 @@ export function ProviderForm({
onManageUniversalProviders,
initialData,
showButtons = true,
isCurrent = false,
}: ProviderFormProps) {
const { t } = useTranslation();
const isEditMode = Boolean(initialData);
@@ -186,13 +187,6 @@ export function ProviderForm({
const isOmoCategory = appId === "opencode" && category === "omo";
const isOmoSlimCategory = appId === "opencode" && category === "omo-slim";
const isAnyOmoCategory = isOmoCategory || isOmoSlimCategory;
const { data: queriedStandardOmoGlobalConfig } =
useOmoGlobalConfig(isOmoCategory);
const { data: queriedSlimOmoGlobalConfig } =
useOmoSlimGlobalConfig(isOmoSlimCategory);
const queriedOmoGlobalConfig = isOmoSlimCategory
? queriedSlimOmoGlobalConfig
: queriedStandardOmoGlobalConfig;
useEffect(() => {
setSelectedPresetId(initialData ? null : "custom");
@@ -243,6 +237,55 @@ export function ProviderForm({
mode: "onSubmit",
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
() => {
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
try {
const config = initialData?.settingsConfig;
if (
config?.env &&
(config.env as Record<string, unknown>).ANTHROPIC_API_KEY !==
undefined
)
return "ANTHROPIC_API_KEY";
} catch {}
return "ANTHROPIC_AUTH_TOKEN";
},
);
const handleApiKeyFieldChange = useCallback(
(field: ClaudeApiKeyField) => {
setLocalApiKeyField(field);
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
env?: Record<string, unknown>;
};
const env = (config.env ?? {}) as Record<string, unknown>;
const oldField =
field === "ANTHROPIC_API_KEY"
? "ANTHROPIC_AUTH_TOKEN"
: "ANTHROPIC_API_KEY";
if (oldField in env) {
env[field] = env[oldField];
delete env[oldField];
config.env = env;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
}
} catch {}
},
[form],
);
const {
apiKey,
handleApiKeyChange,
@@ -253,6 +296,7 @@ export function ProviderForm({
selectedPresetId,
category,
appType: appId,
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
});
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
@@ -276,15 +320,6 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const {
codexAuth,
codexConfig,
@@ -382,37 +417,6 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config),
});
const {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
handleCommonConfigToggle,
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",
});
const {
useCommonConfig: useCodexCommonConfigFlag,
commonConfigSnippet: codexCommonConfigSnippet,
commonConfigError: codexCommonConfigError,
handleCommonConfigToggle: handleCodexCommonConfigToggle,
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
isExtracting: isCodexExtracting,
handleExtract: handleCodexExtract,
} = useCodexCommonConfig({
codexConfig,
onConfigChange: handleCodexConfigChange,
initialData: appId === "codex" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
const {
geminiEnv,
geminiConfig,
@@ -428,7 +432,6 @@ export function ProviderForm({
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
@@ -479,23 +482,6 @@ export function ProviderForm({
[originalHandleGeminiModelChange, updateGeminiEnvField],
);
const {
useCommonConfig: useGeminiCommonConfigFlag,
commonConfigSnippet: geminiCommonConfigSnippet,
commonConfigError: geminiCommonConfigError,
handleCommonConfigToggle: handleGeminiCommonConfigToggle,
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
isExtracting: isGeminiExtracting,
handleExtract: handleGeminiExtract,
} = useGeminiCommonConfig({
envValue: geminiEnv,
onEnvChange: handleGeminiEnvChange,
envStringToObj,
envObjToString,
initialData: appId === "gemini" ? initialData : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
// ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────
const {
@@ -521,7 +507,6 @@ export function ProviderForm({
const omoDraft = useOmoDraftState({
initialOmoSettings,
queriedOmoGlobalConfig,
isEditMode,
appId,
category,
@@ -535,8 +520,6 @@ export function ProviderForm({
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = (values: ProviderFormData) => {
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
@@ -560,7 +543,7 @@ export function ProviderForm({
return;
}
if (appId === "opencode" && category !== "omo") {
if (appId === "opencode" && !isAnyOmoCategory) {
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (!opencodeForm.opencodeProviderKey.trim()) {
toast.error(t("opencode.providerKeyRequired"));
@@ -606,7 +589,8 @@ export function ProviderForm({
}
// 非官方供应商必填校验:端点和 API Key
if (category !== "official") {
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!baseUrl.trim()) {
toast.error(
@@ -691,7 +675,6 @@ export function ProviderForm({
(category === "omo" || category === "omo-slim")
) {
const omoConfig: Record<string, unknown> = {};
omoConfig.useCommonConfig = omoDraft.useOmoCommonConfig;
if (Object.keys(omoDraft.omoAgents).length > 0) {
omoConfig.agents = omoDraft.omoAgents;
}
@@ -740,9 +723,10 @@ export function ProviderForm({
};
if (appId === "opencode") {
if (category === "omo") {
if (isAnyOmoCategory) {
if (!isEditMode) {
payload.providerKey = `omo-${crypto.randomUUID().slice(0, 8)}`;
const prefix = category === "omo" ? "omo" : "omo-slim";
payload.providerKey = `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}
} else {
payload.providerKey = opencodeForm.opencodeProviderKey;
@@ -751,8 +735,8 @@ export function ProviderForm({
payload.providerKey = openclawForm.openclawProviderKey;
}
if (category === "omo" && !payload.presetCategory) {
payload.presetCategory = "omo";
if (isAnyOmoCategory && !payload.presetCategory) {
payload.presetCategory = category;
}
if (activePreset) {
@@ -829,6 +813,10 @@ export function ProviderForm({
appId === "claude" && category !== "official"
? localApiFormat
: undefined,
apiKeyField:
appId === "claude" && category !== "official"
? localApiKeyField
: undefined,
};
onSubmit(payload);
@@ -851,7 +839,8 @@ export function ProviderForm({
);
}, [groupedPresets]);
const shouldShowSpeedTest = category !== "official";
const shouldShowSpeedTest =
category !== "official" && category !== "cloud_provider";
const {
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
@@ -1003,10 +992,10 @@ export function ProviderForm({
const preset = entry.preset as OpenCodeProviderPreset;
const config = preset.settingsConfig;
if (preset.category === "omo") {
if (preset.category === "omo" || preset.category === "omo-slim") {
omoDraft.resetOmoDraftState();
form.reset({
name: "OMO",
name: preset.category === "omo" ? "OMO" : "OMO Slim",
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({}, null, 2),
icon: preset.icon ?? "",
@@ -1066,6 +1055,12 @@ export function ProviderForm({
setLocalApiFormat("anthropic");
}
if (preset.apiKeyField) {
setLocalApiKeyField(preset.apiKeyField);
} else {
setLocalApiKeyField("ANTHROPIC_AUTH_TOKEN");
}
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
@@ -1110,7 +1105,7 @@ export function ProviderForm({
<BasicFormFields
form={form}
beforeNameSlot={
appId === "opencode" && category !== "omo" ? (
appId === "opencode" && !isAnyOmoCategory ? (
<div className="space-y-2">
<Label htmlFor="opencode-key">
{t("opencode.providerKey")}
@@ -1235,10 +1230,11 @@ export function ProviderForm({
{appId === "claude" && (
<ClaudeFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.getValues("settingsConfig"),
isEditMode,
)}
shouldShowApiKey={
(category !== "cloud_provider" ||
hasApiKeyField(form.getValues("settingsConfig"), "claude")) &&
shouldShowApiKey(form.getValues("settingsConfig"), isEditMode)
}
apiKey={apiKey}
onApiKeyChange={handleApiKeyChange}
category={category}
@@ -1270,6 +1266,8 @@ export function ProviderForm({
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
onApiFormatChange={handleApiFormatChange}
apiKeyField={localApiKeyField}
onApiKeyFieldChange={handleApiKeyFieldChange}
/>
)}
@@ -1329,7 +1327,7 @@ export function ProviderForm({
/>
)}
{appId === "opencode" && category !== "omo" && (
{appId === "opencode" && !isAnyOmoCategory && (
<OpenCodeFormFields
npm={opencodeForm.opencodeNpm}
onNpmChange={opencodeForm.handleOpencodeNpmChange}
@@ -1396,15 +1394,8 @@ export function ProviderForm({
configValue={codexConfig}
onAuthChange={setCodexAuth}
onConfigChange={handleCodexConfigChange}
useCommonConfig={useCodexCommonConfigFlag}
onCommonConfigToggle={handleCodexCommonConfigToggle}
commonConfigSnippet={codexCommonConfigSnippet}
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
commonConfigError={codexCommonConfigError}
authError={codexAuthError}
configError={codexConfigError}
onExtract={handleCodexExtract}
isExtracting={isCodexExtracting}
/>
{settingsConfigErrorField}
</>
@@ -1415,36 +1406,23 @@ export function ProviderForm({
configValue={geminiConfig}
onEnvChange={handleGeminiEnvChange}
onConfigChange={handleGeminiConfigChange}
useCommonConfig={useGeminiCommonConfigFlag}
onCommonConfigToggle={handleGeminiCommonConfigToggle}
commonConfigSnippet={geminiCommonConfigSnippet}
onCommonConfigSnippetChange={
handleGeminiCommonConfigSnippetChange
}
commonConfigError={geminiCommonConfigError}
envError={envError}
configError={geminiConfigError}
onExtract={handleGeminiExtract}
isExtracting={isGeminiExtracting}
/>
{settingsConfigErrorField}
</>
) : appId === "opencode" &&
(category === "omo" || category === "omo-slim") ? (
<OmoCommonConfigEditor
previewValue={omoDraft.mergedOmoJsonPreview}
useCommonConfig={omoDraft.useOmoCommonConfig}
onCommonConfigToggle={omoDraft.setUseOmoCommonConfig}
isModalOpen={omoDraft.isOmoConfigModalOpen}
onEditClick={omoDraft.handleOmoEditClick}
onModalClose={() => omoDraft.setIsOmoConfigModalOpen(false)}
onSave={omoDraft.handleOmoGlobalConfigSave}
isSaving={omoDraft.isOmoSaving}
onGlobalConfigStateChange={omoDraft.setOmoGlobalState}
globalConfigRef={omoDraft.omoGlobalConfigRef}
fieldsKey={omoDraft.omoFieldsKey}
isSlim={category === "omo-slim"}
/>
<div className="space-y-2">
<Label>{t("provider.configJson")}</Label>
<JsonEditor
value={omoDraft.mergedOmoJsonPreview}
onChange={() => {}}
rows={14}
showValidation={false}
language="json"
/>
</div>
) : appId === "opencode" &&
category !== "omo" &&
category !== "omo-slim" ? (
@@ -1499,25 +1477,53 @@ export function ProviderForm({
</>
) : (
<>
<CommonConfigEditor
value={form.getValues("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
commonConfigSnippet={commonConfigSnippet}
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
commonConfigError={commonConfigError}
onEditClick={() => setIsCommonConfigModalOpen(true)}
isModalOpen={isCommonConfigModalOpen}
onModalClose={() => setIsCommonConfigModalOpen(false)}
onExtract={handleClaudeExtract}
isExtracting={isClaudeExtracting}
/>
<div className="space-y-2">
<Label htmlFor="settingsConfig">
{t("claudeConfig.configLabel")}
</Label>
{isEditMode && isCurrent && (
<ClaudeQuickToggles
onPatchApplied={(patch) => {
try {
const cfg = JSON.parse(
form.getValues("settingsConfig") || "{}",
);
jsonMergePatch(cfg, patch);
form.setValue(
"settingsConfig",
JSON.stringify(cfg, null, 2),
);
} catch {
// invalid JSON in editor — skip mirror
}
}}
/>
)}
<JsonEditor
value={form.watch("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)}
placeholder={`{
"env": {
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
rows={14}
showValidation={true}
language="json"
/>
<p className="text-xs text-muted-foreground">
{t(
isCurrent
? "claudeConfig.fullSettingsHint"
: "claudeConfig.fragmentSettingsHint",
)}
</p>
</div>
{settingsConfigErrorField}
</>
)}
{category !== "omo" && appId !== "opencode" && appId !== "openclaw" && (
{!isAnyOmoCategory && appId !== "opencode" && appId !== "openclaw" && (
<ProviderAdvancedConfig
testConfig={testConfig}
proxyConfig={proxyConfig}
@@ -1,5 +1,4 @@
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
import type { OmoGlobalConfig } from "@/types/omo";
import type { PricingModelSourceOption } from "../ProviderAdvancedConfig";
// ── Default configs ──────────────────────────────────────────────────
@@ -52,15 +51,6 @@ export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
2,
);
export const EMPTY_OMO_GLOBAL_CONFIG: OmoGlobalConfig = {
id: "global",
disabledAgents: [],
disabledMcps: [],
disabledHooks: [],
disabledSkills: [],
updatedAt: "",
};
// ── Pure functions ───────────────────────────────────────────────────
export function isKnownOpencodeOptionKey(key: string): boolean {
@@ -6,12 +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";
export { useOmoModelSource } from "./useOmoModelSource";
export { useOpencodeFormState } from "./useOpencodeFormState";
export { useOmoDraftState } from "./useOmoDraftState";
@@ -12,6 +12,7 @@ interface UseApiKeyStateProps {
selectedPresetId: string | null;
category?: ProviderCategory;
appType?: string;
apiKeyField?: string;
}
/**
@@ -24,6 +25,7 @@ export function useApiKeyState({
selectedPresetId,
category,
appType,
apiKeyField,
}: UseApiKeyStateProps) {
const [apiKey, setApiKey] = useState(() => {
if (initialConfig) {
@@ -58,7 +60,7 @@ export function useApiKeyState({
initialConfig || "{}",
key.trim(),
{
// 最佳实践:仅在新增模式”且“非官方类别时补齐缺失字段
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
// - 新增模式:selectedPresetId !== null
// - 非官方类别:category !== undefined && category !== "official"
// - 官方类别:不创建字段(UI 也会禁用输入框)
@@ -68,12 +70,20 @@ export function useApiKeyState({
category !== undefined &&
category !== "official",
appType,
apiKeyField,
},
);
onConfigChange(configString);
},
[initialConfig, selectedPresetId, category, appType, onConfigChange],
[
initialConfig,
selectedPresetId,
category,
appType,
apiKeyField,
onConfigChange,
],
);
const showApiKey = useCallback(
@@ -1,308 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
} from "@/utils/providerConfigUtils";
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`;
interface UseCodexCommonConfigProps {
codexConfig: string;
onConfigChange: (config: string) => void;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
selectedPresetId?: string;
}
/**
* Codex (TOML )
* config.json localStorage
*/
export function useCodexCommonConfig({
codexConfig,
onConfigChange,
initialData,
selectedPresetId,
}: UseCodexCommonConfigProps) {
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);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
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);
}
}
}
}, [
initialData,
commonConfigSnippet,
isLoading,
codexConfig,
onConfigChange,
]);
// 处理通用配置开关
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]);
// 从编辑器当前内容提取通用配置片段
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const extracted = await configApi.extractCommonConfigSnippet("codex", {
settingsConfig: JSON.stringify({
config: codexConfig ?? "",
}),
});
if (!extracted || !extracted.trim()) {
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
// 保存到后端
await configApi.setCommonConfigSnippet("codex", extracted);
} catch (error) {
console.error("提取 Codex 通用配置失败:", error);
setCommonConfigError(
t("codexConfig.extractFailed", { error: String(error) }),
);
} finally {
setIsExtracting(false);
}
}, [codexConfig, t]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
};
}
@@ -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,22 +1,11 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { OmoGlobalConfig } from "@/types/omo";
import { useState, useCallback, useMemo } from "react";
import {
mergeOmoConfigPreview,
mergeOmoSlimConfigPreview,
buildOmoSlimProfilePreview,
} from "@/types/omo";
import { type OmoGlobalConfigFieldsRef } from "../OmoGlobalConfigFields";
import * as configApi from "@/lib/api/config";
import {
EMPTY_OMO_GLOBAL_CONFIG,
buildOmoProfilePreview,
} from "../helpers/opencodeFormUtils";
} from "@/types/omo";
interface UseOmoDraftStateParams {
initialOmoSettings: Record<string, unknown> | undefined;
queriedOmoGlobalConfig: OmoGlobalConfig | undefined;
isEditMode: boolean;
appId: string;
category?: string;
@@ -33,33 +22,15 @@ export interface OmoDraftState {
>;
omoOtherFieldsStr: string;
setOmoOtherFieldsStr: React.Dispatch<React.SetStateAction<string>>;
useOmoCommonConfig: boolean;
setUseOmoCommonConfig: React.Dispatch<React.SetStateAction<boolean>>;
isOmoConfigModalOpen: boolean;
setIsOmoConfigModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
isOmoSaving: boolean;
omoGlobalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
omoFieldsKey: number;
effectiveOmoGlobalConfig: OmoGlobalConfig;
mergedOmoJsonPreview: string;
handleOmoGlobalConfigSave: () => Promise<void>;
handleOmoEditClick: () => void;
resetOmoDraftState: (useCommonConfig?: boolean) => void;
setOmoGlobalState: React.Dispatch<
React.SetStateAction<OmoGlobalConfig | null>
>;
resetOmoDraftState: () => void;
}
export function useOmoDraftState({
initialOmoSettings,
queriedOmoGlobalConfig,
isEditMode,
appId,
category,
}: UseOmoDraftStateParams): OmoDraftState {
const { t } = useTranslation();
const isSlim = category === "omo-slim";
const commonConfigKey = isSlim ? "omo_slim" : "omo";
const [omoAgents, setOmoAgents] = useState<
Record<string, Record<string, unknown>>
@@ -82,118 +53,25 @@ export function useOmoDraftState({
return otherFields ? JSON.stringify(otherFields, null, 2) : "";
});
const [omoGlobalState, setOmoGlobalState] = useState<OmoGlobalConfig | null>(
null,
);
const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false);
const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => {
const raw = initialOmoSettings?.useCommonConfig;
return typeof raw === "boolean" ? raw : true;
});
const [isOmoSaving, setIsOmoSaving] = useState(false);
const omoGlobalConfigRef = useRef<OmoGlobalConfigFieldsRef>(null);
const [omoFieldsKey, setOmoFieldsKey] = useState(0);
const effectiveOmoGlobalConfig =
omoGlobalState ?? queriedOmoGlobalConfig ?? EMPTY_OMO_GLOBAL_CONFIG;
const mergedOmoJsonPreview = useMemo(() => {
if (useOmoCommonConfig) {
if (isSlim) {
const merged = mergeOmoSlimConfigPreview(
effectiveOmoGlobalConfig,
omoAgents,
omoOtherFieldsStr,
);
return JSON.stringify(merged, null, 2);
}
const merged = mergeOmoConfigPreview(
effectiveOmoGlobalConfig,
omoAgents,
omoCategories,
omoOtherFieldsStr,
);
return JSON.stringify(merged, null, 2);
} else {
if (isSlim) {
return JSON.stringify(
buildOmoSlimProfilePreview(omoAgents, omoOtherFieldsStr),
null,
2,
);
}
if (isSlim) {
return JSON.stringify(
buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),
buildOmoSlimProfilePreview(omoAgents, omoOtherFieldsStr),
null,
2,
);
}
}, [
useOmoCommonConfig,
effectiveOmoGlobalConfig,
omoAgents,
omoCategories,
omoOtherFieldsStr,
isSlim,
]);
return JSON.stringify(
buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),
null,
2,
);
}, [omoAgents, omoCategories, omoOtherFieldsStr, isSlim]);
// Auto-detect whether common config has content for new OMO/OMO Slim profiles
useEffect(() => {
if (
appId !== "opencode" ||
(category !== "omo" && category !== "omo-slim") ||
isEditMode
)
return;
let active = true;
(async () => {
let next = false;
try {
const raw = await configApi.getCommonConfigSnippet(commonConfigKey);
if (raw) {
const parsed = JSON.parse(raw) as Record<string, unknown>;
next = Object.keys(parsed).some(
(k) => k !== "id" && k !== "updatedAt",
);
}
} catch {}
if (active) setUseOmoCommonConfig(next);
})();
return () => {
active = false;
};
}, [appId, category, isEditMode, commonConfigKey]);
const handleOmoGlobalConfigSave = useCallback(async () => {
if (!omoGlobalConfigRef.current) return;
setIsOmoSaving(true);
try {
const config = omoGlobalConfigRef.current.buildCurrentConfigStrict();
await configApi.setCommonConfigSnippet(
commonConfigKey,
JSON.stringify(config),
);
setIsOmoConfigModalOpen(false);
toast.success(
t("omo.globalConfigSaved", { defaultValue: "Global config saved" }),
);
} catch (err) {
toast.error(String(err));
} finally {
setIsOmoSaving(false);
}
}, [t, commonConfigKey]);
const handleOmoEditClick = useCallback(() => {
setOmoFieldsKey((k) => k + 1);
setIsOmoConfigModalOpen(true);
}, []);
const resetOmoDraftState = useCallback((useCommonConfig = true) => {
const resetOmoDraftState = useCallback(() => {
setOmoAgents({});
setOmoCategories({});
setOmoOtherFieldsStr("");
setUseOmoCommonConfig(useCommonConfig);
}, []);
return {
@@ -203,18 +81,7 @@ export function useOmoDraftState({
setOmoCategories,
omoOtherFieldsStr,
setOmoOtherFieldsStr,
useOmoCommonConfig,
setUseOmoCommonConfig,
isOmoConfigModalOpen,
setIsOmoConfigModalOpen,
isOmoSaving,
omoGlobalConfigRef,
omoFieldsKey,
effectiveOmoGlobalConfig,
mergedOmoJsonPreview,
handleOmoGlobalConfigSave,
handleOmoEditClick,
resetOmoDraftState,
setOmoGlobalState,
};
}
+7 -8
View File
@@ -525,15 +525,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{isLoadingTools || loadingTools[toolName] ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : tool?.version ? (
<div className="flex items-center gap-1.5">
{tool.latest_version &&
tool.version !== tool.latest_version && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
{tool.latest_version}
</span>
)}
tool.latest_version &&
tool.version !== tool.latest_version ? (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
{tool.latest_version}
</span>
) : (
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
)
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
+132 -11
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Pencil, RotateCcw, Check, X } from "lucide-react";
import { Pencil, RotateCcw, Check, X, Download, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -67,9 +67,20 @@ export function BackupListSection({
onSettingsChange,
}: BackupListSectionProps) {
const { t } = useTranslation();
const { backups, isLoading, restore, isRestoring, rename, isRenaming } =
useBackupManager();
const {
backups,
isLoading,
create,
isCreating,
restore,
isRestoring,
rename,
isRenaming,
remove,
isDeleting,
} = useBackupManager();
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
const [deleteFilename, setDeleteFilename] = useState<string | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
@@ -110,6 +121,26 @@ export function BackupListSection({
setEditValue("");
};
const handleDelete = async () => {
if (!deleteFilename) return;
try {
await remove(deleteFilename);
setDeleteFilename(null);
toast.success(
t("settings.backupManager.deleteSuccess", {
defaultValue: "Backup deleted",
}),
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("settings.backupManager.deleteFailed", {
defaultValue: "Delete failed",
});
toast.error(detail);
}
};
const handleConfirmRename = async () => {
if (!editingFilename || !editValue.trim()) return;
try {
@@ -221,11 +252,45 @@ export function BackupListSection({
{/* Backup list */}
<div>
<h4 className="text-sm font-medium mb-2">
{t("settings.backupManager.title", {
defaultValue: "Database Backups",
})}
</h4>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">
{t("settings.backupManager.title", {
defaultValue: "Database Backups",
})}
</h4>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
disabled={isCreating || isRestoring}
onClick={async () => {
try {
await create();
toast.success(
t("settings.backupManager.createSuccess", {
defaultValue: "Backup created successfully",
}),
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("settings.backupManager.createFailed", {
defaultValue: "Backup failed",
});
toast.error(detail);
}
}}
>
<Download className="h-3 w-3 mr-1" />
{isCreating
? t("settings.backupManager.creating", {
defaultValue: "Backing up...",
})
: t("settings.backupManager.createBackup", {
defaultValue: "Backup Now",
})}
</Button>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground py-2">Loading...</div>
@@ -298,18 +363,30 @@ export function BackupListSection({
size="icon"
className="h-7 w-7"
onClick={() => handleStartRename(backup.filename)}
disabled={isRestoring || isRenaming}
disabled={isRestoring || isRenaming || isDeleting}
title={t("settings.backupManager.rename", {
defaultValue: "Rename",
})}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setDeleteFilename(backup.filename)}
disabled={isRestoring || isDeleting}
title={t("settings.backupManager.delete", {
defaultValue: "Delete",
})}
>
<Trash2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={isRestoring}
disabled={isRestoring || isDeleting}
onClick={() => setConfirmFilename(backup.filename)}
>
<RotateCcw className="h-3 w-3 mr-1" />
@@ -329,7 +406,7 @@ export function BackupListSection({
)}
</div>
{/* Confirmation Dialog */}
{/* Restore Confirmation Dialog */}
<Dialog
open={!!confirmFilename}
onOpenChange={(open) => !open && setConfirmFilename(null)}
@@ -368,6 +445,50 @@ export function BackupListSection({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={!!deleteFilename}
onOpenChange={(open) => !open && setDeleteFilename(null)}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader>
<DialogTitle>
{t("settings.backupManager.deleteConfirmTitle", {
defaultValue: "Confirm Delete",
})}
</DialogTitle>
<DialogDescription>
{t("settings.backupManager.deleteConfirmMessage", {
defaultValue:
"This backup will be permanently deleted. This action cannot be undone.",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteFilename(null)}
disabled={isDeleting}
>
{t("common.cancel", { defaultValue: "Cancel" })}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting
? t("settings.backupManager.deleting", {
defaultValue: "Deleting...",
})
: t("settings.backupManager.delete", {
defaultValue: "Delete",
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+20 -7
View File
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
import { ToggleRow } from "@/components/ui/toggle-row";
import { AnimatePresence, motion } from "framer-motion";
interface WindowSettingsProps {
settings: SettingsFormState;
@@ -27,13 +28,25 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<ToggleRow
icon={<EyeOff className="h-4 w-4 text-green-500" />}
title={t("settings.silentStartup")}
description={t("settings.silentStartupDescription")}
checked={!!settings.silentStartup}
onCheckedChange={(value) => onChange({ silentStartup: value })}
/>
<AnimatePresence initial={false}>
{settings.launchOnStartup && (
<motion.div
key="silent-startup"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
>
<ToggleRow
icon={<EyeOff className="h-4 w-4 text-green-500" />}
title={t("settings.silentStartup")}
description={t("settings.silentStartupDescription")}
checked={!!settings.silentStartup}
onCheckedChange={(value) => onChange({ silentStartup: value })}
/>
</motion.div>
)}
</AnimatePresence>
<ToggleRow
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
+276 -22
View File
@@ -1,12 +1,24 @@
import React, { useState, useEffect, useCallback } from "react";
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Calendar, Trash2, Plus } from "lucide-react";
import { Calendar, Trash2, Plus, Search, X, FolderOpen } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import { workspaceApi, type DailyMemoryFileInfo } from "@/lib/api/workspace";
import {
workspaceApi,
type DailyMemoryFileInfo,
type DailyMemorySearchResult,
} from "@/lib/api/workspace";
interface DailyMemoryPanelProps {
isOpen: boolean;
@@ -46,6 +58,16 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
// Delete state
const [deletingFile, setDeletingFile] = useState<string | null>(null);
// Search state
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchResults, setSearchResults] = useState<DailyMemorySearchResult[]>(
[],
);
const [searching, setSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Dark mode
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -61,6 +83,100 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
return () => observer.disconnect();
}, []);
// Whether we are in active search mode (search open with a non-empty term)
const isActiveSearch = useMemo(
() => isSearchOpen && searchTerm.trim().length > 0,
[isSearchOpen, searchTerm],
);
// Debounced search execution
const executeSearch = useCallback(
async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
setSearching(false);
return;
}
setSearching(true);
try {
const results = await workspaceApi.searchDailyMemoryFiles(query.trim());
setSearchResults(results);
} catch (err) {
console.error("Failed to search daily memory files:", err);
toast.error(t("workspace.dailyMemory.searchFailed"));
} finally {
setSearching(false);
}
},
[t],
);
// Handle search input change with debounce
const handleSearchChange = useCallback(
(value: string) => {
setSearchTerm(value);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
void executeSearch(value);
}, 300);
},
[executeSearch],
);
// Open search bar
const openSearch = useCallback(() => {
setIsSearchOpen(true);
// Focus input on next frame
requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
}, []);
// Close search bar and clear state
const closeSearch = useCallback(() => {
setIsSearchOpen(false);
setSearchTerm("");
setSearchResults([]);
setSearching(false);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
}, []);
// Keyboard shortcut: Cmd/Ctrl+F to open search, Escape to close
useEffect(() => {
if (!isOpen || editingFile) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "f") {
e.preventDefault();
if (!isSearchOpen) {
openSearch();
} else {
searchInputRef.current?.focus();
}
}
if (e.key === "Escape" && isSearchOpen) {
e.preventDefault();
closeSearch();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, editingFile, isSearchOpen, openSearch, closeSearch]);
// Clean up debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Load file list
const loadFiles = useCallback(async () => {
setLoadingList(true);
@@ -142,24 +258,44 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
setEditingFile(null);
}
await loadFiles();
// Re-trigger search if active
if (isSearchOpen && searchTerm.trim()) {
void executeSearch(searchTerm);
}
} catch (err) {
console.error("Failed to delete daily memory file:", err);
toast.error(t("workspace.dailyMemory.deleteFailed"));
setDeletingFile(null);
}
}, [deletingFile, editingFile, loadFiles, t]);
}, [
deletingFile,
editingFile,
loadFiles,
t,
isSearchOpen,
searchTerm,
executeSearch,
]);
// Back from edit mode to list mode
// Back from edit mode to list mode — preserve search state
const handleBackToList = useCallback(() => {
setEditingFile(null);
setContent("");
void loadFiles();
}, [loadFiles]);
// Re-trigger search if active (file content may have changed)
if (isSearchOpen && searchTerm.trim()) {
void executeSearch(searchTerm);
}
}, [loadFiles, isSearchOpen, searchTerm, executeSearch]);
// Close panel entirely
// Close panel entirely — clear search state
const handleClose = useCallback(() => {
setEditingFile(null);
setContent("");
setIsSearchOpen(false);
setSearchTerm("");
setSearchResults([]);
setSearching(false);
onClose();
}, [onClose]);
@@ -214,24 +350,142 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
onClose={handleClose}
>
<div className="space-y-4">
{/* Header with path and create button */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
~/.openclaw/workspace/memory/
</p>
<Button
variant="outline"
size="sm"
onClick={handleCreateToday}
className="gap-1.5"
{/* Header with path, search, and create button */}
<div className="flex items-center justify-between gap-2">
<p
className="text-sm text-muted-foreground shrink-0 cursor-pointer hover:text-foreground transition-colors inline-flex items-center gap-1"
onClick={() => workspaceApi.openDirectory("memory")}
title={t("workspace.openDirectory")}
>
<Plus className="w-3.5 h-3.5" />
{t("workspace.dailyMemory.createToday")}
</Button>
~/.openclaw/workspace/memory/
<FolderOpen className="w-3.5 h-3.5" />
</p>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={isSearchOpen ? closeSearch : openSearch}
title={t("workspace.dailyMemory.searchScopeHint")}
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCreateToday}
className="gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
{t("workspace.dailyMemory.createToday")}
</Button>
</div>
</div>
{/* File list */}
{loadingList ? (
{/* Search bar */}
<AnimatePresence>
{isSearchOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder={t("workspace.dailyMemory.searchPlaceholder")}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchTerm && (
<button
onClick={() => handleSearchChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={closeSearch}
className="text-xs text-muted-foreground h-8 px-2 shrink-0"
>
{t("workspace.dailyMemory.searchCloseHint")}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Content: search results or normal file list */}
{isActiveSearch ? (
// --- Search results ---
searching ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{t("workspace.dailyMemory.searching")}
</div>
) : searchResults.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3 border-2 border-dashed border-border rounded-xl">
<Search className="w-10 h-10 opacity-40" />
<p className="text-sm">
{t("workspace.dailyMemory.noSearchResults")}
</p>
</div>
) : (
<div className="space-y-2">
{searchResults.map((result) => (
<button
key={result.filename}
onClick={() => openFile(result.filename)}
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
>
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
<Calendar className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">
{result.date}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(result.sizeBytes)}
</span>
{result.matchCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{t("workspace.dailyMemory.matchCount", {
count: result.matchCount,
})}
</span>
)}
</div>
{result.snippet && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 whitespace-pre-line">
{result.snippet}
</p>
)}
</div>
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
setDeletingFile(result.filename);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
</div>
</button>
))}
</div>
)
) : // --- Normal file list ---
loadingList ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{t("prompts.loading")}
</div>
@@ -14,6 +14,7 @@ import {
Circle,
Calendar,
ChevronRight,
FolderOpen,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { workspaceApi } from "@/lib/api/workspace";
@@ -83,8 +84,13 @@ const WorkspaceFilesPanel: React.FC = () => {
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
<p
className="text-sm text-muted-foreground mb-6 cursor-pointer hover:text-foreground transition-colors inline-flex items-center gap-1"
onClick={() => workspaceApi.openDirectory("workspace")}
title={t("workspace.openDirectory")}
>
~/.openclaw/workspace/
<FolderOpen className="w-3.5 h-3.5" />
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+162 -42
View File
@@ -92,10 +92,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
ANTHROPIC_MODEL: "glm-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
},
},
category: "cn_official",
@@ -110,10 +110,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
ANTHROPIC_MODEL: "glm-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
},
},
category: "cn_official",
@@ -170,10 +170,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.7",
ANTHROPIC_MODEL: "ZhipuAI/GLM-5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-5",
},
},
category: "aggregator",
@@ -236,10 +236,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
ANTHROPIC_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
},
},
category: "cn_official",
@@ -262,10 +262,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
ANTHROPIC_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
ANTHROPIC_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
},
},
category: "cn_official",
@@ -287,10 +287,10 @@ export const providerPresets: ProviderPreset[] = [
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
ANTHROPIC_AUTH_TOKEN: "",
API_TIMEOUT_MS: "3000000",
ANTHROPIC_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-code-preview-latest",
ANTHROPIC_MODEL: "doubao-seed-2-0-code-preview-latest",
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-2-0-code-preview-latest",
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-2-0-code-preview-latest",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-2-0-code-preview-latest",
},
},
category: "cn_official",
@@ -304,10 +304,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.tbox.cn/api/anthropic",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "Ling-1T",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Ling-1T",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Ling-1T",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-1T",
ANTHROPIC_MODEL: "Ling-2.5-1T",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Ling-2.5-1T",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Ling-2.5-1T",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-2.5-1T",
},
},
category: "cn_official",
@@ -316,12 +316,10 @@ export const providerPresets: ProviderPreset[] = [
name: "AiHubMix",
websiteUrl: "https://aihubmix.com",
apiKeyUrl: "https://aihubmix.com",
// 说明:该供应商使用 ANTHROPIC_API_KEY(而非 ANTHROPIC_AUTH_TOKEN
apiKeyField: "ANTHROPIC_API_KEY",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://aihubmix.com",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
@@ -338,10 +336,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
},
},
category: "aggregator",
@@ -356,10 +354,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.1",
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.5",
},
},
category: "aggregator",
@@ -478,6 +476,61 @@ export const providerPresets: ProviderPreset[] = [
icon: "aicodemirror",
iconColor: "#000000",
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.aicoding.sh",
ANTHROPIC_AUTH_TOKEN: "",
},
},
endpointCandidates: ["https://api.aicoding.sh"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "aicoding", // 促销信息 i18n key
icon: "aicoding",
iconColor: "#000000",
},
{
name: "CrazyRouter",
websiteUrl: "https://www.crazyrouter.com",
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://crazyrouter.com",
ANTHROPIC_AUTH_TOKEN: "",
},
},
endpointCandidates: ["https://crazyrouter.com"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "crazyrouter", // 促销信息 i18n key
icon: "crazyrouter",
iconColor: "#000000",
},
{
name: "SSSAiCode",
websiteUrl: "https://www.sssaicode.com",
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://node-hk.sssaicode.com/api",
ANTHROPIC_AUTH_TOKEN: "",
},
},
endpointCandidates: [
"https://node-hk.sssaicode.com/api",
"https://claude2.sssaicode.com/api",
"https://anti.sssaicode.com/api",
],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "sssaicode", // 促销信息 i18n key
icon: "sssaicode",
iconColor: "#000000",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
@@ -486,10 +539,10 @@ export const providerPresets: ProviderPreset[] = [
env: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.5",
ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.5",
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.5",
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6",
},
},
category: "aggregator",
@@ -533,4 +586,71 @@ export const providerPresets: ProviderPreset[] = [
icon: "xiaomimimo",
iconColor: "#000000",
},
{
name: "AWS Bedrock (AKSK)",
websiteUrl: "https://aws.amazon.com/bedrock/",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL:
"https://bedrock-runtime.${AWS_REGION}.amazonaws.com",
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}",
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}",
AWS_REGION: "${AWS_REGION}",
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-6-v1",
ANTHROPIC_DEFAULT_HAIKU_MODEL:
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
ANTHROPIC_DEFAULT_SONNET_MODEL: "global.anthropic.claude-sonnet-4-6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-6-v1",
CLAUDE_CODE_USE_BEDROCK: "1",
},
},
category: "cloud_provider",
templateValues: {
AWS_REGION: {
label: "AWS Region",
placeholder: "us-west-2",
editorValue: "us-west-2",
},
AWS_ACCESS_KEY_ID: {
label: "Access Key ID",
placeholder: "AKIA...",
editorValue: "",
},
AWS_SECRET_ACCESS_KEY: {
label: "Secret Access Key",
placeholder: "your-secret-key",
editorValue: "",
},
},
icon: "aws",
iconColor: "#FF9900",
},
{
name: "AWS Bedrock (API Key)",
websiteUrl: "https://aws.amazon.com/bedrock/",
settingsConfig: {
apiKey: "",
env: {
ANTHROPIC_BASE_URL:
"https://bedrock-runtime.${AWS_REGION}.amazonaws.com",
AWS_REGION: "${AWS_REGION}",
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-6-v1",
ANTHROPIC_DEFAULT_HAIKU_MODEL:
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
ANTHROPIC_DEFAULT_SONNET_MODEL: "global.anthropic.claude-sonnet-4-6",
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-6-v1",
CLAUDE_CODE_USE_BEDROCK: "1",
},
},
category: "cloud_provider",
templateValues: {
AWS_REGION: {
label: "AWS Region",
placeholder: "us-west-2",
editorValue: "us-west-2",
},
},
icon: "aws",
iconColor: "#FF9900",
},
];
+53
View File
@@ -227,6 +227,59 @@ requires_openai_auth = true`,
icon: "aicodemirror",
iconColor: "#000000",
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"aicoding",
"https://api.aicoding.sh",
"gpt-5.3-codex",
),
endpointCandidates: ["https://api.aicoding.sh"],
isPartner: true,
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
},
{
name: "CrazyRouter",
websiteUrl: "https://www.crazyrouter.com",
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"crazyrouter",
"https://crazyrouter.com/v1",
"gpt-5.3-codex",
),
endpointCandidates: ["https://crazyrouter.com/v1"],
isPartner: true,
partnerPromotionKey: "crazyrouter",
icon: "crazyrouter",
iconColor: "#000000",
},
{
name: "SSSAiCode",
websiteUrl: "https://www.sssaicode.com",
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"sssaicode",
"https://node-hk.sssaicode.com/api",
"gpt-5.3-codex",
),
endpointCandidates: [
"https://node-hk.sssaicode.com/api/v1",
"https://claude2.sssaicode.com/api/v1",
"https://anti.sssaicode.com/api/v1",
],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "sssaicode", // 促销信息 i18n key
icon: "sssaicode",
iconColor: "#000000",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
+64
View File
@@ -139,6 +139,70 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
icon: "aicodemirror",
iconColor: "#000000",
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.aicoding.sh",
GEMINI_MODEL: "gemini-3-pro",
},
},
baseURL: "https://api.aicoding.sh",
model: "gemini-3-pro",
description: "AICoding",
category: "third_party",
isPartner: true,
partnerPromotionKey: "aicoding",
endpointCandidates: ["https://api.aicoding.sh"],
icon: "aicoding",
iconColor: "#000000",
},
{
name: "CrazyRouter",
websiteUrl: "https://www.crazyrouter.com",
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://crazyrouter.com",
GEMINI_MODEL: "gemini-3-pro",
},
},
baseURL: "https://crazyrouter.com",
model: "gemini-3-pro",
description: "CrazyRouter",
category: "third_party",
isPartner: true,
partnerPromotionKey: "crazyrouter",
endpointCandidates: ["https://crazyrouter.com"],
icon: "crazyrouter",
iconColor: "#000000",
},
{
name: "SSSAiCode",
websiteUrl: "https://www.sssaicode.com",
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://node-hk.sssaicode.com/api",
GEMINI_MODEL: "gemini-3-pro",
},
},
baseURL: "https://node-hk.sssaicode.com/api",
model: "gemini-3-pro",
description: "SSSAiCode",
category: "third_party",
isPartner: true,
partnerPromotionKey: "sssaicode",
endpointCandidates: [
"https://node-hk.sssaicode.com/api",
"https://claude2.sssaicode.com/api",
"https://anti.sssaicode.com/api",
],
icon: "sssaicode",
iconColor: "#000000",
},
{
name: "OpenRouter",
websiteUrl: "https://openrouter.ai",
+243 -75
View File
@@ -112,16 +112,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "glm-4.7",
name: "GLM-4.7",
id: "glm-5",
name: "GLM-5",
contextWindow: 128000,
cost: { input: 0.001, output: 0.001 },
},
],
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "zhipu",
icon: "zhipu",
iconColor: "#0F62FE",
templateValues: {
@@ -138,8 +136,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "zhipu/glm-4.7" },
modelCatalog: { "zhipu/glm-4.7": { alias: "GLM" } },
model: { primary: "zhipu/glm-5" },
modelCatalog: { "zhipu/glm-5": { alias: "GLM" } },
},
},
{
@@ -152,16 +150,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "glm-4.7",
name: "GLM-4.7",
id: "glm-5",
name: "GLM-5",
contextWindow: 128000,
cost: { input: 0.001, output: 0.001 },
},
],
},
category: "cn_official",
isPartner: true,
partnerPromotionKey: "zhipu",
icon: "zhipu",
iconColor: "#0F62FE",
templateValues: {
@@ -178,8 +174,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "zhipu-en/glm-4.7" },
modelCatalog: { "zhipu-en/glm-4.7": { alias: "GLM" } },
model: { primary: "zhipu-en/glm-5" },
modelCatalog: { "zhipu-en/glm-5": { alias: "GLM" } },
},
},
{
@@ -192,8 +188,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "qwen3-max",
name: "Qwen3 Max",
id: "qwen3.5-plus",
name: "Qwen3.5 Plus",
contextWindow: 32000,
cost: { input: 0.002, output: 0.006 },
},
@@ -216,8 +212,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "qwen/qwen3-max" },
modelCatalog: { "qwen/qwen3-max": { alias: "Qwen" } },
model: { primary: "qwen/qwen3.5-plus" },
modelCatalog: { "qwen/qwen3.5-plus": { alias: "Qwen" } },
},
},
{
@@ -306,8 +302,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -330,8 +326,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "minimax/MiniMax-M2.1" },
modelCatalog: { "minimax/MiniMax-M2.1": { alias: "MiniMax" } },
model: { primary: "minimax/MiniMax-M2.5" },
modelCatalog: { "minimax/MiniMax-M2.5": { alias: "MiniMax" } },
},
},
{
@@ -344,8 +340,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -368,8 +364,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "minimax-en/MiniMax-M2.1" },
modelCatalog: { "minimax-en/MiniMax-M2.1": { alias: "MiniMax" } },
model: { primary: "minimax-en/MiniMax-M2.5" },
modelCatalog: { "minimax-en/MiniMax-M2.5": { alias: "MiniMax" } },
},
},
{
@@ -465,7 +461,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "doubao-seed-code-preview-latest",
id: "doubao-seed-2-0-code-preview-latest",
name: "DouBao Seed Code Preview",
contextWindow: 128000,
cost: { input: 0.002, output: 0.006 },
@@ -483,9 +479,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "doubaoseed/doubao-seed-code-preview-latest" },
model: { primary: "doubaoseed/doubao-seed-2-0-code-preview-latest" },
modelCatalog: {
"doubaoseed/doubao-seed-code-preview-latest": { alias: "DouBao" },
"doubaoseed/doubao-seed-2-0-code-preview-latest": { alias: "DouBao" },
},
},
},
@@ -498,8 +494,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "Ling-1T",
name: "Ling 1T",
id: "Ling-2.5-1T",
name: "Ling 2.5 1T",
contextWindow: 128000,
cost: { input: 0.001, output: 0.004 },
},
@@ -514,8 +510,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "bailing/Ling-1T" },
modelCatalog: { "bailing/Ling-1T": { alias: "BaiLing" } },
model: { primary: "bailing/Ling-2.5-1T" },
modelCatalog: { "bailing/Ling-2.5-1T": { alias: "BaiLing" } },
},
},
{
@@ -562,8 +558,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -587,11 +583,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "aihubmix/claude-sonnet-4-5-20250929",
primary: "aihubmix/claude-sonnet-4-6",
fallbacks: ["aihubmix/claude-opus-4-6"],
},
modelCatalog: {
"aihubmix/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
"aihubmix/claude-sonnet-4-6": { alias: "Sonnet" },
"aihubmix/claude-opus-4-6": { alias: "Opus" },
},
},
@@ -606,8 +602,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -631,11 +627,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "dmxapi/claude-sonnet-4-5-20250929",
primary: "dmxapi/claude-sonnet-4-6",
fallbacks: ["dmxapi/claude-opus-4-6"],
},
modelCatalog: {
"dmxapi/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
"dmxapi/claude-sonnet-4-6": { alias: "Sonnet" },
"dmxapi/claude-opus-4-6": { alias: "Opus" },
},
},
@@ -650,8 +646,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -675,11 +671,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "openrouter/anthropic/claude-sonnet-4.5",
primary: "openrouter/anthropic/claude-sonnet-4.6",
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
},
modelCatalog: {
"openrouter/anthropic/claude-sonnet-4.5": { alias: "Sonnet" },
"openrouter/anthropic/claude-sonnet-4.6": { alias: "Sonnet" },
"openrouter/anthropic/claude-opus-4.6": { alias: "Opus" },
},
},
@@ -694,8 +690,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "ZhipuAI/GLM-4.7",
name: "GLM-4.7",
id: "ZhipuAI/GLM-5",
name: "GLM-5",
contextWindow: 128000,
cost: { input: 0.001, output: 0.001 },
},
@@ -718,8 +714,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "modelscope/ZhipuAI/GLM-4.7" },
modelCatalog: { "modelscope/ZhipuAI/GLM-4.7": { alias: "GLM" } },
model: { primary: "modelscope/ZhipuAI/GLM-5" },
modelCatalog: { "modelscope/ZhipuAI/GLM-5": { alias: "GLM" } },
},
},
{
@@ -732,8 +728,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "Pro/MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
id: "Pro/MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -750,9 +746,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.1" },
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.5" },
modelCatalog: {
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
},
},
},
@@ -766,8 +762,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "openai-completions",
models: [
{
id: "MiniMaxAI/MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMaxAI/MiniMax-M2.5",
name: "MiniMax M2.5",
contextWindow: 200000,
cost: { input: 0.001, output: 0.004 },
},
@@ -784,9 +780,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
suggestedDefaults: {
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.1" },
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.5" },
modelCatalog: {
"siliconflow-en/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
"siliconflow-en/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
},
},
},
@@ -834,8 +830,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -860,11 +856,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "packycode/claude-sonnet-4-5-20250929",
primary: "packycode/claude-sonnet-4-6",
fallbacks: ["packycode/claude-opus-4-6"],
},
modelCatalog: {
"packycode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
"packycode/claude-sonnet-4-6": { alias: "Sonnet" },
"packycode/claude-opus-4-6": { alias: "Opus" },
},
},
@@ -879,8 +875,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -906,11 +902,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "cubence/claude-sonnet-4-5-20250929",
primary: "cubence/claude-sonnet-4-6",
fallbacks: ["cubence/claude-opus-4-6"],
},
modelCatalog: {
"cubence/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
"cubence/claude-sonnet-4-6": { alias: "Sonnet" },
"cubence/claude-opus-4-6": { alias: "Opus" },
},
},
@@ -925,8 +921,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -952,11 +948,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "aigocode/claude-sonnet-4-5-20250929",
primary: "aigocode/claude-sonnet-4-6",
fallbacks: ["aigocode/claude-opus-4-6"],
},
modelCatalog: {
"aigocode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
"aigocode/claude-sonnet-4-6": { alias: "Sonnet" },
"aigocode/claude-opus-4-6": { alias: "Opus" },
},
},
@@ -971,8 +967,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -998,11 +994,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "rightcode/claude-sonnet-4-5-20250929",
primary: "rightcode/claude-sonnet-4-6",
fallbacks: ["rightcode/claude-opus-4-6"],
},
modelCatalog: {
"rightcode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
"rightcode/claude-sonnet-4-6": { alias: "Sonnet" },
"rightcode/claude-opus-4-6": { alias: "Opus" },
},
},
@@ -1017,8 +1013,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-5-20250929",
name: "Claude Sonnet 4.5",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
@@ -1044,15 +1040,187 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
suggestedDefaults: {
model: {
primary: "aicodemirror/claude-sonnet-4-5-20250929",
primary: "aicodemirror/claude-sonnet-4-6",
fallbacks: ["aicodemirror/claude-opus-4-6"],
},
modelCatalog: {
"aicodemirror/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
"aicodemirror/claude-sonnet-4-6": { alias: "Sonnet" },
"aicodemirror/claude-opus-4-6": { alias: "Opus" },
},
},
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
settingsConfig: {
baseUrl: "https://api.aicoding.sh",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
cost: { input: 5, output: 25 },
},
],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "aicoding/claude-sonnet-4-6",
fallbacks: ["aicoding/claude-opus-4-6"],
},
modelCatalog: {
"aicoding/claude-sonnet-4-6": { alias: "Sonnet" },
"aicoding/claude-opus-4-6": { alias: "Opus" },
},
},
},
{
name: "CrazyRouter",
websiteUrl: "https://www.crazyrouter.com",
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
settingsConfig: {
baseUrl: "https://crazyrouter.com/v1",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
cost: { input: 5, output: 25 },
},
],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "crazyrouter",
icon: "crazyrouter",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "crazyrouter/claude-sonnet-4-6",
fallbacks: ["crazyrouter/claude-opus-4-6"],
},
modelCatalog: {
"crazyrouter/claude-sonnet-4-6": { alias: "Sonnet" },
"crazyrouter/claude-opus-4-6": { alias: "Opus" },
},
},
},
{
name: "SSSAiCode",
websiteUrl: "https://www.sssaicode.com",
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
settingsConfig: {
baseUrl: "https://node-hk.sssaicode.com/api",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15 },
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
contextWindow: 200000,
cost: { input: 5, output: 25 },
},
],
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "sssaicode",
icon: "sssaicode",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "sssaicode/claude-sonnet-4-6",
fallbacks: ["sssaicode/claude-opus-4-6"],
},
modelCatalog: {
"sssaicode/claude-sonnet-4-6": { alias: "Sonnet" },
"sssaicode/claude-opus-4-6": { alias: "Opus" },
},
},
},
// ========== Cloud Providers ==========
{
name: "AWS Bedrock",
websiteUrl: "https://aws.amazon.com/bedrock/",
settingsConfig: {
// 请将 us-west-2 替换为你的 AWS Region
baseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com",
apiKey: "",
api: "bedrock-converse-stream",
models: [
{
id: "anthropic.claude-opus-4-6-20250514-v1:0",
name: "Claude Opus 4.6",
contextWindow: 200000,
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
},
{
id: "anthropic.claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextWindow: 200000,
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
},
{
id: "anthropic.claude-haiku-4-5-20251022-v1:0",
name: "Claude Haiku 4.5",
contextWindow: 200000,
cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
},
],
},
category: "cloud_provider",
icon: "aws",
iconColor: "#FF9900",
},
// ========== Custom Template ==========
{
+200 -19
View File
@@ -21,6 +21,7 @@ export const opencodeNpmPackages = [
{ value: "@ai-sdk/openai", label: "OpenAI" },
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
] as const;
@@ -40,15 +41,15 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
> = {
"@ai-sdk/openai-compatible": [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "glm-4.7",
name: "GLM 4.7",
id: "glm-5",
name: "GLM 5",
contextLimit: 204800,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
@@ -315,6 +316,50 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
},
},
],
"@ai-sdk/amazon-bedrock": [
{
id: "global.anthropic.claude-opus-4-6-v1",
name: "Claude Opus 4.6",
contextLimit: 1000000,
outputLimit: 128000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
{
id: "global.anthropic.claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
{
id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
name: "Claude Haiku 4.5",
contextLimit: 200000,
outputLimit: 64000,
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
{
id: "us.amazon.nova-pro-v1:0",
name: "Amazon Nova Pro",
contextLimit: 300000,
outputLimit: 5000,
modalities: { input: ["text", "image"], output: ["text"] },
},
{
id: "us.meta.llama4-maverick-17b-instruct-v1:0",
name: "Meta Llama 4 Maverick",
contextLimit: 131072,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
{
id: "us.deepseek.r1-v1:0",
name: "DeepSeek R1",
contextLimit: 131072,
outputLimit: 131072,
modalities: { input: ["text"], output: ["text"] },
},
],
"@ai-sdk/anthropic": [
{
id: "claude-sonnet-4-5-20250929",
@@ -441,7 +486,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"glm-4.7": { name: "GLM-4.7" },
"glm-5": { name: "GLM-5" },
},
},
category: "cn_official",
@@ -473,7 +518,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"glm-4.7": { name: "GLM-4.7" },
"glm-5": { name: "GLM-5" },
},
},
category: "cn_official",
@@ -599,7 +644,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"ZhipuAI/GLM-4.7": { name: "GLM-4.7" },
"ZhipuAI/GLM-5": { name: "GLM-5" },
},
},
category: "aggregator",
@@ -703,7 +748,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"MiniMax-M2.1": { name: "MiniMax M2.1" },
"MiniMax-M2.5": { name: "MiniMax M2.5" },
},
},
category: "cn_official",
@@ -735,7 +780,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"MiniMax-M2.1": { name: "MiniMax M2.1" },
"MiniMax-M2.5": { name: "MiniMax M2.5" },
},
},
category: "cn_official",
@@ -767,7 +812,9 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"doubao-seed-code-preview-latest": { name: "Doubao Seed Code Preview" },
"doubao-seed-2-0-code-preview-latest": {
name: "Doubao Seed Code Preview",
},
},
},
category: "cn_official",
@@ -792,7 +839,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"Ling-1T": { name: "Ling 1T" },
"Ling-2.5-1T": { name: "Ling 2.5-1T" },
},
},
category: "cn_official",
@@ -843,7 +890,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
@@ -870,7 +917,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
@@ -897,7 +944,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
"anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" },
"anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" },
},
},
@@ -951,7 +998,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
@@ -979,7 +1026,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
@@ -1008,7 +1055,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
@@ -1069,7 +1116,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
apiKey: "",
},
models: {
"claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
"claude-sonnet-4.6": { name: "Claude Sonnet 4.6" },
"claude-opus-4.6": { name: "Claude Opus 4.6" },
},
},
@@ -1086,7 +1133,141 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
},
{
name: "AICoding",
websiteUrl: "https://www.aicoding.sh",
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "AICoding",
options: {
baseURL: "https://api.aicoding.sh",
apiKey: "",
},
models: {
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "aicoding",
icon: "aicoding",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "CrazyRouter",
websiteUrl: "https://www.crazyrouter.com",
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "CrazyRouter",
options: {
baseURL: "https://crazyrouter.com",
apiKey: "",
},
models: {
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "crazyrouter",
icon: "crazyrouter",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "SSSAiCode",
websiteUrl: "https://www.sssaicode.com",
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "SSSAiCode",
options: {
baseURL: "https://node-hk.sssaicode.com/api",
apiKey: "",
},
models: {
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
"claude-opus-4-6": { name: "Claude Opus 4.6" },
},
},
category: "third_party",
isPartner: true,
partnerPromotionKey: "sssaicode",
icon: "sssaicode",
iconColor: "#000000",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "AWS Bedrock",
websiteUrl: "https://aws.amazon.com/bedrock/",
settingsConfig: {
npm: "@ai-sdk/amazon-bedrock",
name: "AWS Bedrock",
options: {
region: "${region}",
accessKeyId: "${accessKeyId}",
secretAccessKey: "${secretAccessKey}",
},
models: {
"global.anthropic.claude-opus-4-6-v1": { name: "Claude Opus 4.6" },
"global.anthropic.claude-sonnet-4-6": {
name: "Claude Sonnet 4.6",
},
"global.anthropic.claude-haiku-4-5-20251001-v1:0": {
name: "Claude Haiku 4.5",
},
"us.amazon.nova-pro-v1:0": { name: "Amazon Nova Pro" },
"us.meta.llama4-maverick-17b-instruct-v1:0": {
name: "Meta Llama 4 Maverick",
},
"us.deepseek.r1-v1:0": { name: "DeepSeek R1" },
},
},
category: "cloud_provider",
icon: "aws",
iconColor: "#FF9900",
templateValues: {
region: {
label: "AWS Region",
placeholder: "us-west-2",
defaultValue: "us-west-2",
editorValue: "us-west-2",
},
accessKeyId: {
label: "Access Key ID",
placeholder: "AKIA...",
editorValue: "",
},
secretAccessKey: {
label: "Secret Access Key",
placeholder: "your-secret-key",
editorValue: "",
},
},
},
{
name: "OpenAI Compatible",
websiteUrl: "",
+14
View File
@@ -13,6 +13,11 @@ export function useBackupManager() {
queryFn: () => backupsApi.listDbBackups(),
});
const createMutation = useMutation({
mutationFn: () => backupsApi.createDbBackup(),
onSuccess: () => refetch(),
});
const restoreMutation = useMutation({
mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),
onSuccess: async () => {
@@ -34,12 +39,21 @@ export function useBackupManager() {
onSuccess: () => refetch(),
});
const deleteMutation = useMutation({
mutationFn: (filename: string) => backupsApi.deleteDbBackup(filename),
onSuccess: () => refetch(),
});
return {
backups,
isLoading,
create: createMutation.mutateAsync,
isCreating: createMutation.isPending,
restore: restoreMutation.mutateAsync,
isRestoring: restoreMutation.isPending,
rename: renameMutation.mutateAsync,
isRenaming: renameMutation.isPending,
remove: deleteMutation.mutateAsync,
isDeleting: deleteMutation.isPending,
};
}
+65 -71
View File
@@ -51,15 +51,8 @@
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Common Config Snippet",
"commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked",
"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}}",
"fragmentSettingsHint": "Config snippet for this provider; will be written to settings.json when activated",
"hideAttribution": "Hide AI Attribution",
"alwaysThinking": "Extended Thinking",
"enableTeammates": "Teammates Mode"
@@ -125,11 +118,7 @@
"notes": "Notes",
"notesPlaceholder": "e.g., Company dedicated account",
"configJson": "Config JSON",
"writeCommonConfig": "Write common config",
"editCommonConfigButton": "Edit common config",
"configJsonHint": "Please fill in complete Claude Code configuration",
"editCommonConfigTitle": "Edit common config snippet",
"editCommonConfigHint": "Common config snippet will be merged into all providers that enable it",
"addProvider": "Add Provider",
"sortUpdated": "Sort order updated",
"usageSaved": "Usage query configuration saved",
@@ -319,7 +308,17 @@
"rename": "Rename",
"renameSuccess": "Backup renamed",
"renameFailed": "Rename failed",
"namePlaceholder": "Enter new name"
"namePlaceholder": "Enter new name",
"createBackup": "Backup Now",
"creating": "Backing up...",
"createSuccess": "Backup created successfully",
"createFailed": "Backup failed",
"delete": "Delete",
"deleting": "Deleting...",
"deleteSuccess": "Backup deleted",
"deleteFailed": "Delete failed",
"deleteConfirmTitle": "Confirm Delete",
"deleteConfirmMessage": "This backup will be permanently deleted. This action cannot be undone."
},
"webdavSync": {
"title": "WebDAV Cloud Sync",
@@ -637,7 +636,10 @@
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!",
"rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!",
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!"
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!",
"aicoding": "AI Coding offers an exclusive discount for CC Switch users — 10% off your first top-up!",
"crazyrouter": "CrazyRouter offers an exclusive bonus for CC Switch users — 30% extra credit on your first top-up!",
"sssaicode": "SSAI Code offers an exclusive bonus for CC Switch users — $10 extra credit on every top-up!"
},
"parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)",
@@ -667,6 +669,10 @@
"apiFormatHint": "Select the input format for the provider's API",
"apiFormatAnthropic": "Anthropic Messages (Native)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
"authField": "Auth Field",
"authFieldAuthToken": "Auth Token (Default)",
"authFieldApiKey": "API Key",
"authFieldHint": "Most third-party providers use Auth Token; a few require API Key",
"anthropicDefaultHaikuModel": "Default Haiku Model",
"anthropicDefaultSonnetModel": "Default Sonnet Model",
"anthropicDefaultOpusModel": "Default Opus Model",
@@ -738,33 +744,15 @@
"authJsonHint": "Codex auth.json configuration content",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml configuration content",
"writeCommonConfig": "Write Common Config",
"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",
"apiUrlLabel": "API Request URL",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}"
"apiUrlLabel": "API Request URL"
},
"geminiConfig": {
"envFile": "Environment Variables (.env)",
"envFileHint": "Configure Gemini environment variables in .env format",
"configJson": "Configuration File (config.json)",
"configJsonHint": "Configure Gemini extended parameters in JSON format (optional)",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Gemini Common Config Snippet",
"commonConfigHint": "This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)",
"extractFromCurrent": "Extract from Editor",
"extractNoCommonConfig": "No common config available to extract from editor",
"extractFailed": "Extract failed: {{error}}",
"saveFailed": "Save failed: {{error}}",
"extractedConfigInvalid": "Extracted config format is invalid",
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
"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}}"
@@ -800,38 +788,6 @@
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"openclaw": {
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
"apiProtocol": "API Protocol",
"selectProtocol": "Select API Protocol",
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
"baseUrl": "API Endpoint",
"baseUrlHint": "The provider's API endpoint address.",
"models": "Models",
"addModel": "Add Model",
"noModels": "No models configured. Click Add Model to configure available models.",
"modelId": "Model ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "Display Name",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "Context Window",
"maxTokens": "Max Output Tokens",
"reasoning": "Reasoning Mode",
"reasoningOn": "Enabled",
"reasoningOff": "Disabled",
"inputCost": "Input Cost ($/M tokens)",
"outputCost": "Output Cost ($/M tokens)",
"advancedOptions": "Advanced Options",
"cacheReadCost": "Cache Read Cost ($/M tokens)",
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface."
},
"providerPreset": {
"label": "Provider Preset",
"custom": "Custom Configuration",
@@ -1219,22 +1175,60 @@
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"loadFailed": "Failed to load",
"openDirectory": "Open in file manager",
"dailyMemory": {
"title": "Daily Memory",
"sectionTitle": "Daily Memory",
"cardTitle": "Daily Memory Files",
"cardDescription": "Browse & manage daily memories",
"createToday": "Today's Note",
"createToday": "Add Memory",
"empty": "No daily memory files yet",
"loadFailed": "Failed to load daily memory files",
"createFailed": "Failed to create daily memory file",
"deleteSuccess": "Daily memory file deleted",
"deleteFailed": "Failed to delete daily memory file",
"confirmDeleteTitle": "Delete Daily Memory",
"confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone."
"confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone.",
"searchPlaceholder": "Search full content...",
"searchScopeHint": "Full-text search across all daily memories. ⌘F",
"searchCloseHint": "Esc to close",
"noSearchResults": "No daily memories match your search.",
"searching": "Searching...",
"searchFailed": "Search failed",
"matchCount": "{{count}} match(es)"
}
},
"openclaw": {
"providerKey": "Provider Key",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
"providerKeyRequired": "Provider key is required",
"providerKeyDuplicate": "This key is already in use",
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
"apiProtocol": "API Protocol",
"selectProtocol": "Select API Protocol",
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
"baseUrl": "API Endpoint",
"baseUrlHint": "The provider's API endpoint address.",
"models": "Models",
"addModel": "Add Model",
"noModels": "No models configured. Click Add Model to configure available models.",
"modelId": "Model ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "Display Name",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "Context Window",
"maxTokens": "Max Output Tokens",
"reasoning": "Reasoning Mode",
"reasoningOn": "Enabled",
"reasoningOff": "Disabled",
"inputCost": "Input Cost ($/M tokens)",
"outputCost": "Output Cost ($/M tokens)",
"advancedOptions": "Advanced Options",
"cacheReadCost": "Cache Read Cost ($/M tokens)",
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface.",
"env": {
"title": "Environment Variables",
"description": "Manage environment variables in openclaw.json (API keys, custom variables, etc.)",
@@ -1821,10 +1815,6 @@
"enabled": "Enabled",
"disabled": "OMO disabled",
"disableFailed": "Failed to disable OMO: {{error}}",
"writeCommonConfig": "Write to common config",
"editCommonConfig": "Edit common config",
"editCommonConfigTitle": "Common Config",
"commonConfigHint": "OMO common config will be merged into all OMO configs that enable it",
"selectPlaceholder": "Select...",
"clear": "Clear",
"clearWrapped": "(Clear)",
@@ -1854,6 +1844,10 @@
"customCategories": "Custom Categories",
"modelConfiguration": "Model Configuration",
"fillRecommended": "Fill Recommended",
"fillRecommendedSuccess": "Filled {{count}} recommended models",
"fillRecommendedPartial": "Filled {{filled}} recommended models, {{unmatched}} unmatched",
"fillRecommendedAllSet": "All slots already have models configured",
"fillRecommendedNoMatch": "Recommended models not found in configured providers",
"configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
"enabledModelsCount": "{{count}} configured models available",
"source": "from:",
+86 -73
View File
@@ -51,15 +51,8 @@
},
"claudeConfig": {
"configLabel": "Claude Code settings.json (JSON) *",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
"fullSettingsHint": "Claude Code の settings.json 全文",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"fragmentSettingsHint": "このプロバイダーの設定スニペット。有効化時に settings.json に書き込まれます",
"hideAttribution": "AI署名を非表示",
"alwaysThinking": "拡張思考",
"enableTeammates": "Teammates モード"
@@ -125,11 +118,7 @@
"notes": "メモ",
"notesPlaceholder": "例: 会社用アカウント",
"configJson": "Config JSON",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfigButton": "共通設定を編集",
"configJsonHint": "Claude Code の設定をすべて入力してください",
"editCommonConfigTitle": "共通設定スニペットを編集",
"editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます",
"addProvider": "プロバイダーを追加",
"sortUpdated": "並び順を更新しました",
"usageSaved": "利用状況の設定を保存しました",
@@ -319,7 +308,17 @@
"rename": "名前変更",
"renameSuccess": "バックアップの名前を変更しました",
"renameFailed": "名前変更に失敗しました",
"namePlaceholder": "新しい名前を入力"
"namePlaceholder": "新しい名前を入力",
"createBackup": "今すぐバックアップ",
"creating": "バックアップ中...",
"createSuccess": "バックアップが作成されました",
"createFailed": "バックアップに失敗しました",
"delete": "削除",
"deleting": "削除中...",
"deleteSuccess": "バックアップを削除しました",
"deleteFailed": "削除に失敗しました",
"deleteConfirmTitle": "バックアップの削除を確認",
"deleteConfirmMessage": "このバックアップは完全に削除されます。この操作は取り消せません。"
},
"webdavSync": {
"title": "WebDAV クラウド同期",
@@ -637,7 +636,10 @@
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!",
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!"
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!",
"aicoding": "AI Coding は CC Switch ユーザー向けに特別割引を提供しています。初回チャージが 10% オフ!",
"crazyrouter": "CrazyRouter は CC Switch ユーザー向けに特別ボーナスを提供しています。初回チャージで 30% の追加クレジットがもらえます!",
"sssaicode": "SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます!"
},
"parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)",
@@ -667,6 +669,10 @@
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
"authField": "認証フィールド",
"authFieldAuthToken": "Auth Token(デフォルト)",
"authFieldApiKey": "API Key",
"authFieldHint": "ほとんどのサードパーティプロバイダーは Auth Token を使用します。一部は API Key が必要です",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
"anthropicDefaultOpusModel": "既定 Opus モデル",
@@ -738,33 +744,15 @@
"authJsonHint": "Codex の auth.json 設定内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex の config.toml 設定内容",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
"apiUrlLabel": "API リクエスト URL",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}"
"apiUrlLabel": "API リクエスト URL"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
"envFileHint": ".env 形式で Gemini の環境変数を設定",
"configJson": "設定ファイル (config.json)",
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
"commonConfigHint": "このスニペットは Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)",
"extractFromCurrent": "編集内容から抽出",
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
"extractFailed": "抽出に失敗しました: {{error}}",
"saveFailed": "保存に失敗しました: {{error}}",
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}}",
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
@@ -800,38 +788,6 @@
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"openclaw": {
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
"apiProtocol": "API プロトコル",
"selectProtocol": "API プロトコルを選択",
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
"baseUrl": "API エンドポイント",
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
"models": "モデル一覧",
"addModel": "モデルを追加",
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
"modelId": "モデル ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "表示名",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "コンテキストウィンドウ",
"maxTokens": "最大出力トークン数",
"reasoning": "推論モード",
"reasoningOn": "有効",
"reasoningOff": "無効",
"inputCost": "入力コスト ($/M トークン)",
"outputCost": "出力コスト ($/M トークン)",
"advancedOptions": "詳細オプション",
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。"
},
"providerPreset": {
"label": "プロバイダータイプ",
"custom": "カスタム設定",
@@ -1219,22 +1175,60 @@
"saveSuccess": "保存しました",
"saveFailed": "保存に失敗しました",
"loadFailed": "読み込みに失敗しました",
"openDirectory": "ファイルマネージャーで開く",
"dailyMemory": {
"title": "デイリーメモリー",
"sectionTitle": "デイリーメモリー",
"cardTitle": "デイリーメモリーファイル",
"cardDescription": "デイリーメモリーの閲覧・管理",
"createToday": "今日のノート",
"createToday": "メモリーを追加",
"empty": "デイリーメモリーファイルはまだありません",
"loadFailed": "デイリーメモリーファイルの読み込みに失敗しました",
"createFailed": "デイリーメモリーファイルの作成に失敗しました",
"deleteSuccess": "デイリーメモリーファイルを削除しました",
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
"confirmDeleteTitle": "デイリーメモリーを削除",
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。"
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。",
"searchPlaceholder": "全文検索...",
"searchScopeHint": "すべてのデイリーメモリーを全文検索 ⌘F",
"searchCloseHint": "Escで閉じる",
"noSearchResults": "検索に一致するデイリーメモリーはありません。",
"searching": "検索中...",
"searchFailed": "検索に失敗しました",
"matchCount": "{{count}}件一致"
}
},
"openclaw": {
"providerKey": "プロバイダーキー",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
"providerKeyRequired": "プロバイダーキーを入力してください",
"providerKeyDuplicate": "このキーは既に使用されています",
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
"apiProtocol": "API プロトコル",
"selectProtocol": "API プロトコルを選択",
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
"baseUrl": "API エンドポイント",
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
"models": "モデル一覧",
"addModel": "モデルを追加",
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
"modelId": "モデル ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "表示名",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "コンテキストウィンドウ",
"maxTokens": "最大出力トークン数",
"reasoning": "推論モード",
"reasoningOn": "有効",
"reasoningOff": "無効",
"inputCost": "入力コスト ($/M トークン)",
"outputCost": "出力コスト ($/M トークン)",
"advancedOptions": "詳細オプション",
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。",
"env": {
"title": "環境変数",
"description": "openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)",
@@ -1364,6 +1358,7 @@
"http429": "リクエストが多すぎます。時間をおいて再試行してください",
"parseMetadataFailed": "スキルメタデータの解析に失敗しました",
"getHomeDirFailed": "ユーザーのホームディレクトリを取得できません",
"noSkillsInZip": "ZIP ファイルにスキルが見つかりません(SKILL.md ファイルが必要です)",
"networkError": "ネットワークエラー",
"fsError": "ファイルシステムエラー",
"unknownError": "不明なエラー",
@@ -1374,7 +1369,8 @@
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
"checkDiskSpace": "ディスク容量を確認してください",
"checkPermission": "ディレクトリの権限を確認してください",
"uninstallFirst": "同名のスキルを先にアンインストールしてください"
"uninstallFirst": "同名のスキルを先にアンインストールしてください",
"checkZipContent": "ZIP ファイルに有効なスキルディレクトリ(SKILL.md を含む)が含まれていることを確認してください"
}
},
"repo": {
@@ -1644,7 +1640,12 @@
"toast": {
"saved": "プロキシ設定を保存しました",
"saveFailed": "保存に失敗しました: {{error}}"
}
},
"invalidPort": "無効なポートです。1024〜65535 の数値を入力してください",
"invalidAddress": "無効なアドレスです。有効な IP アドレス(例: 127.0.0.1)または localhost を入力してください",
"configSaved": "プロキシ設定を保存しました",
"configSaveFailed": "設定の保存に失敗しました",
"restartRequired": "アドレスまたはポートの変更を反映するにはプロキシサービスの再起動が必要です"
},
"switchFailed": "切り替えに失敗しました: {{error}}",
"failover": {
@@ -1674,6 +1675,7 @@
"info": "フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。",
"configSaved": "自動フェイルオーバー設定を保存しました",
"configSaveFailed": "保存に失敗しました",
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
"retrySettings": "リトライとタイムアウト設定",
"failureThreshold": "失敗しきい値",
"failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)",
@@ -1726,6 +1728,16 @@
"streamingIdle": "ストリーミングアイドルタイムアウト",
"nonStreaming": "非ストリーミングタイムアウト"
},
"circuitBreaker": {
"failureThreshold": "失敗しきい値",
"successThreshold": "成功しきい値",
"timeoutSeconds": "タイムアウト(秒)",
"errorRateThreshold": "エラー率しきい値",
"minRequests": "最小リクエスト数",
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
"configSaved": "サーキットブレーカー設定を保存しました",
"saveFailed": "保存に失敗しました"
},
"universalProvider": {
"title": "統合プロバイダー",
"description": "統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。",
@@ -1750,6 +1762,7 @@
"syncError": "同期に失敗しました",
"noAppsEnabled": "有効なアプリがありません",
"added": "統合プロバイダーを追加しました",
"addedAndSynced": "統合プロバイダーを追加して同期しました",
"updated": "統合プロバイダーを更新しました",
"deleted": "統合プロバイダーを削除しました",
"addSuccess": "統合プロバイダーを追加しました",
@@ -1802,10 +1815,6 @@
"enabled": "有効中",
"disabled": "OMO を無効化しました",
"disableFailed": "OMO の無効化に失敗しました: {{error}}",
"writeCommonConfig": "共通設定に書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "共通設定",
"commonConfigHint": "OMO 共通設定は有効にしたすべての OMO 設定に統合されます",
"selectPlaceholder": "選択してください...",
"clear": "クリア",
"clearWrapped": "(クリア)",
@@ -1835,6 +1844,10 @@
"customCategories": "カスタムカテゴリ",
"modelConfiguration": "モデル設定",
"fillRecommended": "推奨を入力",
"fillRecommendedSuccess": "推奨モデル {{count}} 件を設定しました",
"fillRecommendedPartial": "推奨モデル {{filled}} 件を設定、{{unmatched}} 件は未一致",
"fillRecommendedAllSet": "すべてのスロットにモデルが設定済みです",
"fillRecommendedNoMatch": "推奨モデルが設定済みプロバイダーに見つかりませんでした",
"configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
"enabledModelsCount": "設定済みモデル {{count}} 件",
"source": "出典:",
+65 -71
View File
@@ -51,15 +51,8 @@
},
"claudeConfig": {
"configLabel": "Claude Code 配置 (JSON) *",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑通用配置片段",
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}",
"fragmentSettingsHint": "此供应商的配置片段,激活后将写入 settings.json",
"hideAttribution": "隐藏 AI 署名",
"alwaysThinking": "扩展思考",
"enableTeammates": "Teammates 模式"
@@ -125,11 +118,7 @@
"notes": "备注",
"notesPlaceholder": "例如:公司专用账号",
"configJson": "配置 JSON",
"writeCommonConfig": "写入通用配置",
"editCommonConfigButton": "编辑通用配置",
"configJsonHint": "请填写完整的 Claude Code 配置",
"editCommonConfigTitle": "编辑通用配置片段",
"editCommonConfigHint": "通用配置片段将合并到所有启用它的供应商配置中",
"addProvider": "添加供应商",
"sortUpdated": "排序已更新",
"usageSaved": "用量查询配置已保存",
@@ -319,7 +308,17 @@
"rename": "重命名",
"renameSuccess": "备份重命名成功",
"renameFailed": "重命名失败",
"namePlaceholder": "输入新名称"
"namePlaceholder": "输入新名称",
"createBackup": "立即备份",
"creating": "备份中...",
"createSuccess": "备份创建成功",
"createFailed": "备份创建失败",
"delete": "删除",
"deleting": "删除中...",
"deleteSuccess": "备份已删除",
"deleteFailed": "删除失败",
"deleteConfirmTitle": "确认删除备份",
"deleteConfirmMessage": "此备份将被永久删除,此操作无法撤消。"
},
"webdavSync": {
"title": "WebDAV 云同步",
@@ -637,7 +636,10 @@
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!",
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!"
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!",
"aicoding": "AI Coding 为 CC Switch 的用户提供了特殊优惠,首次充值可以享受 9 折优惠!",
"crazyrouter": "CrazyRouter 为 CC Switch 的用户提供了特殊优惠,首次充值赠予 30% 额外额度!",
"sssaicode": "SSAI Code 为 CC Switch 的用户提供了特殊优惠,每次充值赠予额外 $10 额度!"
},
"parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)",
@@ -667,6 +669,10 @@
"apiFormatHint": "选择供应商 API 的输入格式",
"apiFormatAnthropic": "Anthropic Messages (原生)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
"authField": "认证字段",
"authFieldAuthToken": "Auth Token (默认)",
"authFieldApiKey": "API Key",
"authFieldHint": "大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
"anthropicDefaultOpusModel": "Opus 默认模型",
@@ -738,33 +744,15 @@
"authJsonHint": "Codex auth.json 配置内容",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml 配置内容",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
"apiUrlLabel": "API 请求地址",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}"
"apiUrlLabel": "API 请求地址"
},
"geminiConfig": {
"envFile": "环境变量 (.env)",
"envFileHint": "使用 .env 格式配置 Gemini 环境变量",
"configJson": "配置文件 (config.json)",
"configJsonHint": "使用 JSON 格式配置 Gemini 扩展参数(可选)",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Gemini 通用配置片段",
"commonConfigHint": "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY",
"extractFromCurrent": "从编辑内容提取",
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
"extractFailed": "提取失败: {{error}}",
"saveFailed": "保存失败: {{error}}",
"extractedConfigInvalid": "提取的配置格式错误",
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}}",
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
"configMergeFailed": "配置合并失败: {{error}}",
"configReplaceFailed": "配置替换失败: {{error}}"
@@ -800,38 +788,6 @@
"modelOptionKeyPlaceholder": "provider",
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
},
"openclaw": {
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
"apiProtocol": "API 协议",
"selectProtocol": "选择 API 协议",
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
"baseUrl": "API 端点",
"baseUrlHint": "供应商的 API 端点地址。",
"models": "模型列表",
"addModel": "添加模型",
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
"modelId": "模型 ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "显示名称",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "上下文窗口",
"maxTokens": "最大输出 Tokens",
"reasoning": "推理模式",
"reasoningOn": "启用",
"reasoningOff": "关闭",
"inputCost": "输入价格 ($/M tokens)",
"outputCost": "输出价格 ($/M tokens)",
"advancedOptions": "高级选项",
"cacheReadCost": "缓存读取价格 ($/M tokens)",
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。"
},
"providerPreset": {
"label": "预设供应商",
"custom": "自定义配置",
@@ -1219,22 +1175,60 @@
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"loadFailed": "读取失败",
"openDirectory": "在文件管理器中打开",
"dailyMemory": {
"title": "每日记忆",
"sectionTitle": "每日记忆",
"cardTitle": "每日记忆文件",
"cardDescription": "浏览管理每日记忆",
"createToday": "今日笔记",
"createToday": "添加记忆",
"empty": "暂无每日记忆文件",
"loadFailed": "加载每日记忆文件失败",
"createFailed": "创建每日记忆文件失败",
"deleteSuccess": "每日记忆文件已删除",
"deleteFailed": "删除每日记忆文件失败",
"confirmDeleteTitle": "删除每日记忆",
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。"
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。",
"searchPlaceholder": "搜索全文内容...",
"searchScopeHint": "全文搜索所有每日记忆 ⌘F",
"searchCloseHint": "Esc 关闭",
"noSearchResults": "没有找到匹配的每日记忆。",
"searching": "搜索中...",
"searchFailed": "搜索失败",
"matchCount": "{{count}} 处匹配"
}
},
"openclaw": {
"providerKey": "供应商标识",
"providerKeyPlaceholder": "my-provider",
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
"providerKeyRequired": "请填写供应商标识",
"providerKeyDuplicate": "此标识已被使用,请更换",
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
"apiProtocol": "API 协议",
"selectProtocol": "选择 API 协议",
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
"baseUrl": "API 端点",
"baseUrlHint": "供应商的 API 端点地址。",
"models": "模型列表",
"addModel": "添加模型",
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
"modelId": "模型 ID",
"modelIdPlaceholder": "claude-3-sonnet",
"modelName": "显示名称",
"modelNamePlaceholder": "Claude 3 Sonnet",
"contextWindow": "上下文窗口",
"maxTokens": "最大输出 Tokens",
"reasoning": "推理模式",
"reasoningOn": "启用",
"reasoningOff": "关闭",
"inputCost": "输入价格 ($/M tokens)",
"outputCost": "输出价格 ($/M tokens)",
"advancedOptions": "高级选项",
"cacheReadCost": "缓存读取价格 ($/M tokens)",
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
"env": {
"title": "环境变量",
"description": "管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)",
@@ -1821,10 +1815,6 @@
"enabled": "启用中",
"disabled": "OMO 已停用",
"disableFailed": "停用 OMO 失败: {{error}}",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "通用配置",
"commonConfigHint": "OMO 通用配置将合并到所有启用它的 OMO 配置中",
"selectPlaceholder": "请选择...",
"clear": "清空",
"clearWrapped": "(清空)",
@@ -1854,6 +1844,10 @@
"customCategories": "自定义分类",
"modelConfiguration": "模型配置",
"fillRecommended": "填充推荐",
"fillRecommendedSuccess": "已填充 {{count}} 个推荐模型",
"fillRecommendedPartial": "已填充 {{filled}} 个推荐模型,{{unmatched}} 个未匹配",
"fillRecommendedAllSet": "所有槽位已有模型配置",
"fillRecommendedNoMatch": "推荐模型在已配置的供应商中未找到匹配",
"configSummary": "已配置 {{agents}} 个 Agent{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
"enabledModelsCount": "可选已配置模型 {{count}} 个",
"source": "来源:",
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 3.6.17 -->
<svg width="470" height="470" viewBox="0 0 470 470" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Path" fill="#a78bfa" stroke="none" d="M 33 73 L 137 13 L 263 83 L 159 143 Z"/>
<path id="path1" fill="#a78bfa" stroke="none" opacity="0.92" d="M 33 73 L 33 213 L 159 283 L 159 143 Z"/>
<path id="path2" fill="#ffffff" stroke="none" d="M 207 247 L 311 187 L 431 257 L 327 317 Z"/>
<path id="path3" fill="#a78bfa" stroke="none" opacity="0.92" d="M 207 247 L 207 387 L 327 457 L 327 317 Z"/>
<path id="path4" fill="#fdba74" stroke="none" d="M 327 317 L 431 257 L 431 397 L 327 457 Z"/>
<path id="path5" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 33 73 L 137 13 L 263 83 L 159 143 L 33 73"/>
<path id="path6" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 33 73 L 33 213 L 159 283 L 159 143"/>
<path id="path7" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 159 143 L 263 83 L 263 223"/>
<path id="path8" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 207 247 L 311 187 L 431 257 L 327 317 L 207 247"/>
<path id="path9" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 207 247 L 207 387 L 327 457 L 327 317"/>
<path id="path10" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 327 317 L 431 257 L 431 397 L 327 457"/>
<path id="path11" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 245 163 L 365 163"/>
<path id="path12" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 175 197 L 335 117"/>
<path id="path13" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 365 163 L 365 241"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

+3
View File
@@ -3,6 +3,7 @@
export const icons: Record<string, string> = {
aicodemirror: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 1017.97 1056.47"><title>AICodeMirror</title><path fill="#E4906E" fill-rule="nonzero" d="M944.92 1014.53c-17.29,-9.23 -33.98,-19.28 -50.08,-30.16 -5.39,-3.65 -14.99,-11.7 -28.81,-24.16 -6.06,-5.47 -14.07,-13.51 -24.03,-24.13 -15.15,-16.17 -29.61,-29.9 -41.69,-40.4 -3.98,-3.46 -14.2,-11.02 -30.68,-22.69 -6.24,-4.42 -12.88,-12.15 -18.63,-19.09 -23.98,-29.04 -49.53,-58.44 -76.66,-88.19 -11.93,-13.1 -25.64,-26.11 -35.61,-36.5 -28.72,-29.92 -51.92,-51.96 -78.23,-79.66 -11.24,-11.83 -20.52,-21.3 -27.85,-28.41 -0.56,-0.53 -1.3,-0.84 -2.08,-0.84 -0.51,0 -1.02,0.14 -1.47,0.39 -9.76,5.54 -17.53,14.33 -24.91,23.31 -10.71,13.01 -21.86,26.65 -33.44,40.91 -4.37,5.38 -7.9,9.46 -10.56,12.24 -5.43,5.67 -9.83,10.88 -15.08,15.39 -9.57,8.23 -19.57,16.01 -29.98,23.31 -14.73,10.32 -29.07,20.29 -43.04,29.9 -5.22,3.61 -13.68,9.81 -20.14,15.41 -25.71,22.32 -53.59,46.12 -83.65,71.41 -9.46,7.95 -19.65,16.88 -34.02,29.22 -25.66,22.03 -52.94,40.06 -81.67,55.65 -7.71,4.19 -14.15,8.2 -19.32,12.01 -19.3,14.23 -33.84,25.65 -43.62,34.28 -25.99,22.91 -43.04,37.82 -51.16,44.73 -9.19,7.8 -18.6,17.05 -28.42,25.54 -2.71,2.35 -5.7,3.03 -8.96,2.01 -0.78,-0.24 -1.25,-1.02 -1.1,-1.82 0.36,-2 1.19,-4.1 2.47,-6.32 6.86,-11.81 14.46,-23.09 19.95,-36.03 3.48,-8.23 7.87,-16.52 13.18,-24.89 2.03,-3.19 4.73,-8.77 8.11,-16.74 2.98,-7.02 7.34,-15.05 13.07,-24.12 5.79,-9.14 16,-23.36 30.63,-42.67 7.66,-10.11 19.49,-23.6 35.49,-40.47 4.9,-5.16 12.21,-11.87 21.92,-20.14 12.12,-10.31 23.53,-21.19 34.23,-32.65 11.73,-12.54 16.99,-22.33 27.39,-40.8 2.37,-4.19 6.49,-9.43 12.37,-15.71 8.27,-8.82 17,-17.23 26.21,-25.23 30.11,-26.18 55.17,-47.43 75.17,-63.76 8.66,-7.08 26.42,-21.39 39.65,-30.77 17.11,-12.13 28.62,-20.44 34.53,-24.91 4.5,-3.4 8.93,-6.6 13.3,-10.56 26.03,-23.54 51.66,-45.71 77.28,-70.7 0.42,-0.41 0.51,-1.06 0.21,-1.58 -6.8,-11.78 -12.84,-21.8 -18.11,-30.06 -10.22,-15.99 -22.07,-29.65 -35.57,-40.99 -7.56,-6.36 -18.41,-13.85 -28.65,-20.43 -15.08,-9.66 -30.62,-21.97 -46.63,-36.9 -35.08,-32.73 -67.65,-71.22 -85.32,-115.42 -5.53,-13.85 -10.8,-29.31 -15.8,-46.37 -5.89,-20.13 -12.37,-35.63 -23.22,-51.27 -8.93,-12.9 -15.77,-21.94 -19.58,-35.93 -1.27,-4.67 -2.93,-12.75 -4.99,-24.23 -2.07,-11.54 -6.54,-22.62 -13.41,-33.25 -7.54,-11.68 -13.66,-21.04 -18.33,-28.08 -3.68,-5.53 -7.02,-12.39 -9.63,-18.53 -3.9,-9.18 -8.14,-15.7 -13.6,-23.37 -3.94,-5.53 -5.07,-12.75 0,-18.32 4.14,-4.57 17.49,-3.02 21.56,-1.13 3.86,1.81 8.1,5.13 12.71,9.94 16.16,16.88 26.41,27.77 30.74,32.66 4.69,5.31 11.21,13.79 16.69,19.94 20.19,22.63 36.17,39.74 47.36,59.71 10.46,18.66 16.41,30.42 29.84,44.67 9.32,9.92 17.94,19.33 25.85,28.23 9.01,10.15 19.25,22.95 30.72,38.39 7.54,10.17 13.89,20.11 19.05,29.84 6.39,12.05 10.8,30.19 15.13,41.41 4.88,12.67 12.52,23.25 22.92,31.75 0.58,0.47 6.79,5.44 18.62,14.89 13.54,10.82 23.74,23.47 30.61,37.96 4.55,9.58 7.82,16.16 9.8,19.74 6.62,11.85 14.64,22.05 24.07,30.59 8.99,8.14 17.47,13.2 31.06,22.64 4.28,2.96 6.68,5.98 10.65,2.54 7.08,-6.11 13.73,-10.71 17.96,-14.53 6.12,-5.54 11.71,-11.84 16.79,-18.92 3.5,-4.88 8.77,-10.16 15.19,-14.42 22.77,-15.02 38.17,-31.11 63.32,-55.15 22.13,-21.17 46.22,-47.56 69.25,-66.8 32.17,-26.89 54.99,-45.9 68.46,-57.01 17.15,-14.15 35.82,-30.97 56.02,-50.46 16.06,-15.5 29.25,-27.72 39.57,-36.66 9.78,-8.47 17.55,-14.8 23.31,-18.98 8.52,-6.2 18.55,-10.61 30.56,-15.03 12.34,-4.55 23.44,-11.06 35.67,-17.61 9.07,-4.85 19.76,-9.89 30.05,-8.84 0.5,0.06 0.97,0.32 1.3,0.72 0.85,1.07 1.01,2.48 0.48,4.23 -2.1,6.99 -5.15,13.55 -9.66,20.87 -6.42,10.42 -11.51,19.46 -15.29,27.11 -6.09,12.35 -9.66,19.49 -10.69,21.43 -7.78,14.65 -17.56,27.97 -29.34,39.97 -4.8,4.89 -12.93,12.92 -24.37,24.07 -14.23,13.87 -25.02,30.77 -37.12,50.78 -15.21,25.13 -29.56,48.47 -43.06,70.01 -5.21,8.29 -13.68,15.13 -21.58,21.47 -25.71,20.7 -46.75,41.48 -70.98,64.67 -1.97,1.88 -4.98,4.47 -9.03,7.76 -22.62,18.36 -45.88,35.93 -69.78,52.74 -5.96,4.2 -13.77,11.24 -23.42,21.11 -17.12,17.5 -25.93,26.56 -26.42,27.19 -1.22,1.54 -1.08,3.09 0.41,4.67 14.11,14.81 34.25,37.65 60.42,68.51 11.89,14.01 24.87,27.08 36.03,39.46 8.75,9.7 16.81,22.11 31.82,42.59 2.69,3.68 13.53,16.07 32.5,37.17 5.17,5.76 11.64,14.47 19.4,26.12 16.37,24.6 36.28,56.2 59.73,94.79 4.2,6.92 7.74,12.33 10.62,16.21 5.41,7.29 10.37,13.74 14.92,20.97 6.26,9.94 11.3,19.92 15.11,29.92 4.29,11.27 7.73,19.49 10.32,24.69 7.21,14.5 14.81,28.41 22.8,41.73 3.44,5.75 6.78,13.03 6.11,20.05 -0.07,0.76 -0.71,1.34 -1.48,1.34 -0.25,0 -0.49,-0.06 -0.71,-0.18l0 0.01z"/></svg>`,
aicoding: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 470 470"><title>AICoding</title><path fill="#a78bfa" d="M 33 73 L 137 13 L 263 83 L 159 143 Z"/><path fill="#a78bfa" opacity="0.92" d="M 33 73 L 33 213 L 159 283 L 159 143 Z"/><path fill="#fff" d="M 207 247 L 311 187 L 431 257 L 327 317 Z"/><path fill="#a78bfa" opacity="0.92" d="M 207 247 L 207 387 L 327 457 L 327 317 Z"/><path fill="#fdba74" d="M 327 317 L 431 257 L 431 397 L 327 457 Z"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 33 73 L 137 13 L 263 83 L 159 143 L 33 73"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 33 73 L 33 213 L 159 283 L 159 143"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 159 143 L 263 83 L 263 223"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 207 247 L 311 187 L 431 257 L 327 317 L 207 247"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 207 247 L 207 387 L 327 457 L 327 317"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 327 317 L 431 257 L 431 397 L 327 457"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 245 163 L 365 163"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 175 197 L 335 117"/><path fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 365 163 L 365 241"/></svg>`,
aigocode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 648 564" xmlns="http://www.w3.org/2000/svg"><title>AiGoCode</title><g transform="translate(0,564) scale(0.1,-0.1)"><path fill="#7C6AEF" d="M5392 5379 c-26 -10 -36 -28 -56 -108 -24 -94 -47 -140 -101 -199 -56 -62 -117 -96 -219 -121 -101 -25 -116 -35 -116 -75 0 -45 21 -60 123 -89 190 -53 271 -147 336 -384 13 -46 38 -61 85 -49 25 6 36 28 56 111 7 28 23 72 36 99 13 28 21 54 18 58 -3 5 -2 7 3 6 4 -2 29 18 55 44 41 41 145 110 173 114 56 8 126 27 131 36 4 6 7 30 8 54 1 49 -5 54 -104 74 -164 33 -280 148 -320 320 -23 101 -52 129 -108 109z"/><path fill="#5B7FFF" d="M1770 4814 c-138 -25 -301 -93 -425 -177 -80 -55 -227 -197 -280 -272 -98 -138 -161 -279 -201 -447 -16 -66 -18 -164 -21 -1098 -4 -1130 -4 -1144 57 -1331 80 -242 222 -434 444 -598 l56 -42 0 -337 c0 -309 2 -340 19 -379 31 -66 87 -93 158 -74 22 6 202 117 404 249 200 131 398 259 439 285 144 89 48 81 1095 88 912 5 932 6 1012 27 243 64 441 178 599 344 166 176 274 408 304 648 13 110 13 2016 0 2120 -25 190 -83 347 -183 498 -160 240 -384 400 -677 484 l-75 22 -1325 2 c-1086 2 -1339 0 -1400 -12z m1068 -1455 l-3 -751 -71 -18 c-71 -18 -154 -55 -205 -91 -14 -10 -29 -16 -33 -12 -3 3 -6 91 -6 195 l0 188 -306 0 -305 0 -21 -47 c-11 -27 -33 -75 -48 -108 -16 -33 -44 -96 -62 -140 l-34 -80 -172 -3 c-101 -1 -172 1 -172 7 0 5 14 40 31 78 31 68 101 227 254 578 335 769 358 814 434 874 94 74 122 79 449 80 l272 1 -2 -751z m794 717 c41 -13 103 -42 139 -62 67 -40 202 -168 232 -221 l17 -31 -77 -65 c-43 -35 -101 -80 -129 -101 l-52 -36 -39 57 c-79 116 -204 177 -313 154 -66 -14 -105 -42 -130 -91 -19 -37 -20 -60 -20 -400 0 -339 1 -363 20 -399 26 -51 61 -78 128 -97 143 -42 327 52 366 186 l13 45 -163 3 -163 2 -3 157 c-2 111 0 157 9 160 6 2 263 3 570 1 487 -3 562 -5 593 -19 98 -45 125 -159 58 -244 -39 -50 -66 -55 -322 -55 l-236 0 -6 -27 c-18 -83 -29 -115 -62 -183 -69 -143 -230 -269 -402 -316 -81 -22 -271 -25 -350 -5 -173 43 -289 145 -341 298 -19 57 -20 81 -17 534 l3 474 33 67 c55 112 168 199 307 235 79 20 246 10 337 -21z m828 -1515 c67 -71 67 -73 -62 -396 -45 -110 -102 -254 -128 -320 -254 -639 -272 -681 -298 -702 -74 -62 -192 -15 -192 77 0 12 39 116 86 233 48 117 97 239 110 272 119 311 336 833 354 852 36 37 85 32 130 -16z m-1574 -205 c16 -13 19 -29 22 -128 l3 -113 -195 -155 c-108 -85 -196 -158 -196 -161 0 -4 17 -19 38 -35 128 -96 327 -272 339 -301 16 -39 17 -143 2 -177 -15 -33 -34 -39 -67 -22 -48 26 -566 446 -584 474 -23 35 -23 89 1 128 16 27 150 143 376 326 39 31 79 65 90 75 44 41 128 103 139 103 7 0 21 -6 32 -14z m713 -25 c52 -37 53 -33 -92 -551 -36 -129 -80 -289 -98 -355 -39 -143 -62 -175 -129 -175 -47 0 -92 20 -114 52 -24 34 -19 90 18 217 19 64 78 271 131 461 53 190 98 353 101 364 6 16 14 18 79 14 54 -4 81 -11 104 -27z m1116 -351 c55 -45 117 -98 138 -120 61 -64 48 -115 -50 -192 -32 -25 -118 -94 -191 -152 -136 -108 -163 -120 -217 -100 -26 10 -47 62 -39 97 10 46 22 62 94 119 36 29 93 77 128 106 l62 55 -65 59 c-71 65 -77 84 -49 141 24 47 44 67 68 67 12 0 66 -36 121 -80z"/><path fill="#5B7FFF" d="M2328 3755 c-37 -20 -54 -53 -153 -280 -48 -110 -96 -219 -106 -242 l-18 -43 234 0 235 0 0 290 0 290 -82 0 c-53 -1 -93 -6 -110 -15z"/></g></svg>`,
alibaba: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Alibaba</title><path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z" fill="#FF6003" fill-rule="evenodd"></path></svg>`,
anthropic: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>`,
@@ -15,6 +16,7 @@ export const icons: Record<string, string> = {
cloudflare: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437" fill="#F38020"></path><path d="M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777" fill="#FCAD32"></path></svg>`,
cohere: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>`,
copilot: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Copilot</title><path d="M17.533 1.829A2.528 2.528 0 0015.11 0h-.737a2.531 2.531 0 00-2.484 2.087l-1.263 6.937.314-1.08a2.528 2.528 0 012.424-1.833h4.284l1.797.706 1.731-.706h-.505a2.528 2.528 0 01-2.423-1.829l-.715-2.453z" fill="url(#lobe-icons-copilot-fill-0)" transform="translate(0 1)"></path><path d="M6.726 20.16A2.528 2.528 0 009.152 22h1.566c1.37 0 2.49-1.1 2.525-2.48l.17-6.69-.357 1.228a2.528 2.528 0 01-2.423 1.83h-4.32l-1.54-.842-1.667.843h.497c1.124 0 2.113.75 2.426 1.84l.697 2.432z" fill="url(#lobe-icons-copilot-fill-1)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-2)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-3)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-4)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-5)" transform="translate(0 1)"></path><defs><radialGradient cx="85.44%" cy="100.653%" fx="85.44%" fy="100.653%" gradientTransform="scale(-.8553 -1) rotate(50.927 2.041 -1.946)" id="lobe-icons-copilot-fill-0" r="105.116%"><stop offset="9.6%" stop-color="#00AEFF"></stop><stop offset="77.3%" stop-color="#2253CE"></stop><stop offset="100%" stop-color="#0736C4"></stop></radialGradient><radialGradient cx="18.143%" cy="32.928%" fx="18.143%" fy="32.928%" gradientTransform="scale(.8897 1) rotate(52.069 .193 .352)" id="lobe-icons-copilot-fill-1" r="95.612%"><stop offset="0%" stop-color="#FFB657"></stop><stop offset="63.4%" stop-color="#FF5F3D"></stop><stop offset="92.3%" stop-color="#C02B3C"></stop></radialGradient><radialGradient cx="82.987%" cy="-9.792%" fx="82.987%" fy="-9.792%" gradientTransform="scale(-1 -.9441) rotate(-70.872 .142 1.17)" id="lobe-icons-copilot-fill-4" r="140.622%"><stop offset="6.6%" stop-color="#8C48FF"></stop><stop offset="50%" stop-color="#F2598A"></stop><stop offset="89.6%" stop-color="#FFB152"></stop></radialGradient><linearGradient id="lobe-icons-copilot-fill-2" x1="39.465%" x2="46.884%" y1="12.117%" y2="103.774%"><stop offset="15.6%" stop-color="#0D91E1"></stop><stop offset="48.7%" stop-color="#52B471"></stop><stop offset="65.2%" stop-color="#98BD42"></stop><stop offset="93.7%" stop-color="#FFC800"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-3" x1="45.949%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#3DCBFF"></stop><stop offset="24.7%" stop-color="#0588F7" stop-opacity="0"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-5" x1="83.507%" x2="83.453%" y1="-6.106%" y2="21.131%"><stop offset="5.8%" stop-color="#F8ADFA"></stop><stop offset="70.8%" stop-color="#A86EDD" stop-opacity="0"></stop></linearGradient></defs></svg>`,
crazyrouter: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 563 648"><title>CrazyRouter</title><path fill="currentColor" d="M 167.5 190 L 276.5 190 L 277 191.5 L 253 234 L 247.5 235 L 246.5 234 L 235.5 234 L 230.5 235 L 229.5 234 L 217.5 234 L 215.5 234 L 191.5 234 L 189.5 235 L 186.5 234 L 164.5 235 L 150.5 239 Q 132.7 246.7 121 260.5 Q 108.2 275.2 102 296.5 L 99 312.5 L 99 335.5 L 102 351.5 L 110 371.5 Q 118.1 386.4 130.5 397 Q 143.5 409 164.5 413 L 173.5 413 L 174.5 414 L 258.5 414 L 314.5 318 L 433.5 318 Q 449.3 314.8 457 303.5 L 462 294.5 L 465 283.5 L 465 269.5 L 460 253.5 L 449.5 241 L 440.5 236 L 430.5 234 L 332.5 234 L 258.5 361 L 212.5 361 L 212 359.5 L 310.5 190 L 438.5 190 L 448.5 192 L 461.5 197 Q 476 205 486 217.5 L 496 233.5 L 502 250.5 L 504 260.5 L 504 268.5 L 505 269.5 L 505 283.5 L 504 284.5 L 503 297.5 L 499 311.5 Q 490.8 331.8 475.5 345 L 462.5 354 L 446 360.5 L 502 452.5 L 504 458 L 456.5 458 L 454 455.5 L 416 389.5 L 398.5 362 L 336.5 362 L 335 363.5 L 285 452.5 L 280.5 458 L 167.5 458 L 166.5 457 L 153.5 456 Q 112.4 445.6 90 416.5 Q 72.7 396.3 64 367.5 L 59 343.5 L 58 314.5 L 59 313.5 L 60 297.5 L 64 280.5 L 73 257.5 Q 85.3 233.8 104.5 217 Q 116.8 206.3 132.5 199 L 149.5 193 L 166.5 191 L 167.5 190 Z"/></svg>`,
cubence: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 179 203" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cubence</title><rect width="100" height="100" rx="13" transform="matrix(0.866025 -0.5 0 1 92 103)" fill="#4B5563"></rect><rect width="100" height="100" rx="13" transform="matrix(0.866025 0.5 -0.866025 0.5 88.6025 -3)" fill="#1F2937"></rect><rect width="100" height="100" rx="13" transform="matrix(0.866025 0.5 0 1 0 53)" fill="#111827"></rect><rect width="72.7816" height="72.7816" rx="13" transform="matrix(0.866025 0.5 0 1 11 73)" fill="#374151"></rect><rect width="28.1436" height="28.1436" rx="3" transform="matrix(0.866025 0.5 0 1 11 86)" fill="#E5E7EB" fill-opacity="0.9"></rect><rect width="28.1436" height="28.1436" rx="3" transform="matrix(0.866025 0.5 0 1 50 107)" fill="#E5E7EB" fill-opacity="0.9"></rect><rect width="13.8564" height="13.8564" rx="3" transform="matrix(0.866025 0.5 0 1 43 148)" fill="#E5E7EB" fill-opacity="0.9"></rect></svg>`,
deepseek: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>`,
doubao: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>`,
@@ -58,6 +60,7 @@ export const icons: Record<string, string> = {
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
opencode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 240 300" xmlns="http://www.w3.org/2000/svg"><title>OpenCode</title><g clip-path="url(#clip0_1401_86274)"><mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300"><path d="M240 0H0V300H240V0Z" fill="white"/></mask><g mask="url(#mask0_1401_86274)"><path d="M180 240H60V120H180V240Z" fill="#CFCECD"/><path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/></g></g><defs><clipPath id="clip0_1401_86274"><rect width="240" height="300" fill="white"/></clipPath></defs></svg>`,
siliconflow: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>`,
sssaicode: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 512 512"><title>SSAI Code</title><defs><linearGradient id="ssc-gradLeft" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#0ff5ce" /><stop offset="100%" stop-color="#147a8a" /></linearGradient><linearGradient id="ssc-gradRight" x1="100%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#d0e4f5" /><stop offset="100%" stop-color="#6a9ec4" /></linearGradient><linearGradient id="ssc-gradTop" x1="50%" y1="0%" x2="50%" y2="100%"><stop offset="0%" stop-color="#a0d8e8" /><stop offset="100%" stop-color="#4aafbf" /></linearGradient><linearGradient id="ssc-gradText" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#0ff5ce" /><stop offset="35%" stop-color="#4abfcf" /><stop offset="65%" stop-color="#7badd4" /><stop offset="100%" stop-color="#c0daf0" /></linearGradient><linearGradient id="ssc-gradS" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#f0f8ff" /><stop offset="100%" stop-color="#6cbfcf" /></linearGradient><filter id="ssc-glow" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="4" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter><pattern id="ssc-binL" x="0" y="0" width="55" height="16" patternUnits="userSpaceOnUse" patternTransform="rotate(-3)"><text x="0" y="11" font-family="monospace" font-size="8" fill="rgba(0,255,210,0.25)">1001 1101</text></pattern><pattern id="ssc-binR" x="0" y="0" width="55" height="16" patternUnits="userSpaceOnUse" patternTransform="rotate(3)"><text x="0" y="11" font-family="monospace" font-size="8" fill="rgba(180,210,240,0.25)">0110 1011</text></pattern><pattern id="ssc-binT" x="0" y="0" width="50" height="16" patternUnits="userSpaceOnUse"><text x="2" y="11" font-family="monospace" font-size="8" fill="rgba(120,200,220,0.2)">10 110</text></pattern></defs><rect width="512" height="512" rx="72" fill="#08080e" /><polygon points="90,350 250,350 170,228" fill="url(#ssc-gradLeft)" opacity="0.8" /><polygon points="90,350 250,350 170,228" fill="url(#ssc-binL)" /><polygon points="262,350 422,350 342,228" fill="url(#ssc-gradRight)" opacity="0.8" /><polygon points="262,350 422,350 342,228" fill="url(#ssc-binR)" /><polygon points="176,290 336,290 256,168" fill="none" stroke="url(#ssc-gradTop)" stroke-width="2.5" opacity="0.85" /><polygon points="192,280 320,280 256,184" fill="none" stroke="url(#ssc-gradTop)" stroke-width="0.8" opacity="0.35" /><text x="256" y="316" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="120" font-weight="bold" fill="url(#ssc-gradS)" filter="url(#ssc-glow)">S</text><text x="256" y="425" text-anchor="middle" font-family="'Helvetica Neue', 'Segoe UI', Arial, sans-serif" font-size="40" font-weight="300" letter-spacing="5" fill="url(#ssc-gradText)">SSSAiCode</text></svg>`,
catcoder: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>`,
mcp: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>`,
nvidia: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>`,
+73
View File
@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<!-- Teal-cyan gradient for left triangle -->
<linearGradient id="gradLeft" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0ff5ce" />
<stop offset="100%" stop-color="#147a8a" />
</linearGradient>
<!-- Light blue-white gradient for right triangle -->
<linearGradient id="gradRight" x1="100%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#d0e4f5" />
<stop offset="100%" stop-color="#6a9ec4" />
</linearGradient>
<!-- Top triangle gradient -->
<linearGradient id="gradTop" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#a0d8e8" />
<stop offset="100%" stop-color="#4aafbf" />
</linearGradient>
<!-- Text gradient -->
<linearGradient id="gradText" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0ff5ce" />
<stop offset="35%" stop-color="#4abfcf" />
<stop offset="65%" stop-color="#7badd4" />
<stop offset="100%" stop-color="#c0daf0" />
</linearGradient>
<!-- S letter gradient -->
<linearGradient id="gradS" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#f0f8ff" />
<stop offset="100%" stop-color="#6cbfcf" />
</linearGradient>
<!-- Glow filter -->
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- Binary pattern left -->
<pattern id="binL" x="0" y="0" width="55" height="16" patternUnits="userSpaceOnUse" patternTransform="rotate(-3)">
<text x="0" y="11" font-family="monospace" font-size="8" fill="rgba(0,255,210,0.25)">1001 1101</text>
</pattern>
<!-- Binary pattern right -->
<pattern id="binR" x="0" y="0" width="55" height="16" patternUnits="userSpaceOnUse" patternTransform="rotate(3)">
<text x="0" y="11" font-family="monospace" font-size="8" fill="rgba(180,210,240,0.25)">0110 1011</text>
</pattern>
<!-- Binary pattern top -->
<pattern id="binT" x="0" y="0" width="50" height="16" patternUnits="userSpaceOnUse">
<text x="2" y="11" font-family="monospace" font-size="8" fill="rgba(120,200,220,0.2)">10 110</text>
</pattern>
</defs>
<!-- Background -->
<rect width="512" height="512" rx="72" fill="#08080e" />
<!-- Left triangle (bottom-left, overlapping center) -->
<polygon points="90,350 250,350 170,228" fill="url(#gradLeft)" opacity="0.8" />
<polygon points="90,350 250,350 170,228" fill="url(#binL)" />
<!-- Right triangle (bottom-right, overlapping center) -->
<polygon points="262,350 422,350 342,228" fill="url(#gradRight)" opacity="0.8" />
<polygon points="262,350 422,350 342,228" fill="url(#binR)" />
<!-- Top triangle (center, overlapping both) -->
<polygon points="176,290 336,290 256,168" fill="none" stroke="url(#gradTop)" stroke-width="2.5" opacity="0.85" />
<!-- Subtle inner triangle -->
<polygon points="192,280 320,280 256,184" fill="none" stroke="url(#gradTop)" stroke-width="0.8" opacity="0.35" />
<!-- Central "S" with glow -->
<text x="256" y="316" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-size="120" font-weight="bold" fill="url(#gradS)" filter="url(#glow)">S</text>
<!-- "SSSAiCode" text -->
<text x="256" y="425" text-anchor="middle" font-family="'Helvetica Neue', 'Segoe UI', Arial, sans-serif" font-size="40" font-weight="300" letter-spacing="5" fill="url(#gradText)">SSSAiCode</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

-77
View File
@@ -1,77 +0,0 @@
// 配置相关 API
import { invoke } from "@tauri-apps/api/core";
export type AppType = "claude" | "codex" | "gemini" | "omo" | "omo_slim";
/**
* Claude 使 getCommonConfigSnippet
* @returns JSON null
* @deprecated 使 getCommonConfigSnippet('claude')
*/
export async function getClaudeCommonConfigSnippet(): Promise<string | null> {
return invoke<string | null>("get_claude_common_config_snippet");
}
/**
* Claude 使 setCommonConfigSnippet
* @param snippet - JSON
* @throws JSON
* @deprecated 使 setCommonConfigSnippet('claude', snippet)
*/
export async function setClaudeCommonConfigSnippet(
snippet: string,
): Promise<void> {
return invoke("set_claude_common_config_snippet", { snippet });
}
/**
*
* @param appType - claude/codex/gemini
* @returns null
*/
export async function getCommonConfigSnippet(
appType: AppType,
): Promise<string | null> {
return invoke<string | null>("get_common_config_snippet", { appType });
}
/**
*
* @param appType - claude/codex/gemini
* @param snippet -
* @throws Claude/Gemini JSONCodex
*/
export async function setCommonConfigSnippet(
appType: AppType,
snippet: string,
): Promise<void> {
return invoke("set_common_config_snippet", { appType, snippet });
}
/**
*
*
* `options.settingsConfig`
* API Key
*
* @param appType - claude/codex/gemini
* @param options -
* @returns JSON/TOML
*/
export type ExtractCommonConfigSnippetOptions = {
settingsConfig?: string;
};
export async function extractCommonConfigSnippet(
appType: Exclude<AppType, "omo">,
options?: ExtractCommonConfigSnippetOptions,
): Promise<string> {
const args: Record<string, unknown> = { appType };
const settingsConfig = options?.settingsConfig;
if (typeof settingsConfig === "string" && settingsConfig.trim()) {
args.settingsConfig = settingsConfig;
}
return invoke<string>("extract_common_config_snippet", args);
}
-1
View File
@@ -11,6 +11,5 @@ export { proxyApi } from "./proxy";
export { openclawApi } from "./openclaw";
export { sessionsApi } from "./sessions";
export { workspaceApi } from "./workspace";
export * as configApi from "./config";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
-3
View File
@@ -5,7 +5,6 @@ export const omoApi = {
readLocalFile: (): Promise<OmoLocalFileData> => invoke("read_omo_local_file"),
getCurrentOmoProviderId: (): Promise<string> =>
invoke("get_current_omo_provider_id"),
getOmoProviderCount: (): Promise<number> => invoke("get_omo_provider_count"),
disableCurrentOmo: (): Promise<void> => invoke("disable_current_omo"),
};
@@ -14,7 +13,5 @@ export const omoSlimApi = {
invoke("read_omo_slim_local_file"),
getCurrentProviderId: (): Promise<string> =>
invoke("get_current_omo_slim_provider_id"),
getProviderCount: (): Promise<number> =>
invoke("get_omo_slim_provider_count"),
disableCurrent: (): Promise<void> => invoke("disable_current_omo_slim"),
};
+8
View File
@@ -223,6 +223,10 @@ export interface BackupEntry {
}
export const backupsApi = {
async createDbBackup(): Promise<string> {
return await invoke("create_db_backup");
},
async listDbBackups(): Promise<BackupEntry[]> {
return await invoke("list_db_backups");
},
@@ -234,4 +238,8 @@ export const backupsApi = {
async renameDbBackup(oldFilename: string, newName: string): Promise<string> {
return await invoke("rename_db_backup", { oldFilename, newName });
},
async deleteDbBackup(filename: string): Promise<void> {
await invoke("delete_db_backup", { filename });
},
};
+21
View File
@@ -8,6 +8,15 @@ export interface DailyMemoryFileInfo {
preview: string;
}
export interface DailyMemorySearchResult {
filename: string;
date: string;
sizeBytes: number;
modifiedAt: number;
snippet: string;
matchCount: number;
}
export const workspaceApi = {
async readFile(filename: string): Promise<string | null> {
return invoke<string | null>("read_workspace_file", { filename });
@@ -32,4 +41,16 @@ export const workspaceApi = {
async deleteDailyMemoryFile(filename: string): Promise<void> {
return invoke<void>("delete_daily_memory_file", { filename });
},
async searchDailyMemoryFiles(
query: string,
): Promise<DailyMemorySearchResult[]> {
return invoke<DailyMemorySearchResult[]>("search_daily_memory_files", {
query,
});
},
async openDirectory(subdir: "workspace" | "memory"): Promise<void> {
await invoke("open_workspace_directory", { subdir });
},
};
+21 -2
View File
@@ -19,8 +19,12 @@ export const useAddProviderMutation = (appId: AppId) => {
let id: string;
if (appId === "opencode" || appId === "openclaw") {
if (providerInput.category === "omo") {
id = `omo-${generateUUID()}`;
if (
providerInput.category === "omo" ||
providerInput.category === "omo-slim"
) {
const prefix = providerInput.category === "omo" ? "omo" : "omo-slim";
id = `${prefix}-${generateUUID()}`;
} else {
if (!providerInput.providerKey) {
throw new Error(`Provider key is required for ${appId}`);
@@ -53,6 +57,12 @@ export const useAddProviderMutation = (appId: AppId) => {
await queryClient.invalidateQueries({
queryKey: ["omo", "provider-count"],
});
await queryClient.invalidateQueries({
queryKey: ["omo-slim", "current-provider-id"],
});
await queryClient.invalidateQueries({
queryKey: ["omo-slim", "provider-count"],
});
}
try {
@@ -135,6 +145,12 @@ export const useDeleteProviderMutation = (appId: AppId) => {
await queryClient.invalidateQueries({
queryKey: ["omo", "provider-count"],
});
await queryClient.invalidateQueries({
queryKey: ["omo-slim", "current-provider-id"],
});
await queryClient.invalidateQueries({
queryKey: ["omo-slim", "provider-count"],
});
}
try {
@@ -186,6 +202,9 @@ export const useSwitchProviderMutation = (appId: AppId) => {
await queryClient.invalidateQueries({
queryKey: ["omo", "current-provider-id"],
});
await queryClient.invalidateQueries({
queryKey: ["omo-slim", "current-provider-id"],
});
}
if (appId === "openclaw") {
await queryClient.invalidateQueries({
-74
View File
@@ -1,16 +1,12 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { omoApi, omoSlimApi } from "@/lib/api/omo";
import * as configApi from "@/lib/api/config";
import type { OmoGlobalConfig } from "@/types/omo";
// ── Factory ────────────────────────────────────────────────────
function createOmoQueryKeys(prefix: string) {
return {
all: [prefix] as const,
globalConfig: () => [prefix, "global-config"] as const,
currentProviderId: () => [prefix, "current-provider-id"] as const,
providerCount: () => [prefix, "provider-count"] as const,
};
}
@@ -19,49 +15,10 @@ function createOmoQueryHooks(
api: typeof omoApi | typeof omoSlimApi,
) {
const keys = createOmoQueryKeys(variant);
const snippetKey = variant === "omo" ? "omo" : "omo_slim";
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: keys.globalConfig() });
queryClient.invalidateQueries({ queryKey: ["providers"] });
queryClient.invalidateQueries({ queryKey: keys.currentProviderId() });
queryClient.invalidateQueries({ queryKey: keys.providerCount() });
}
function useGlobalConfig(enabled = true) {
return useQuery({
queryKey: keys.globalConfig(),
enabled,
queryFn: async (): Promise<OmoGlobalConfig> => {
const raw = await configApi.getCommonConfigSnippet(snippetKey);
if (!raw) {
return {
id: "global",
disabledAgents: [],
disabledMcps: [],
disabledHooks: [],
disabledSkills: [],
updatedAt: new Date().toISOString(),
};
}
try {
return JSON.parse(raw) as OmoGlobalConfig;
} catch (error) {
console.warn(
`[${variant}] invalid global config json, fallback to defaults`,
error,
);
return {
id: "global",
disabledAgents: [],
disabledMcps: [],
disabledHooks: [],
disabledSkills: [],
updatedAt: new Date().toISOString(),
};
}
},
});
}
function useCurrentProviderId(enabled = true) {
@@ -75,28 +32,6 @@ function createOmoQueryHooks(
});
}
function useProviderCount(enabled = true) {
return useQuery({
queryKey: keys.providerCount(),
queryFn:
"getOmoProviderCount" in api
? (api as typeof omoApi).getOmoProviderCount
: (api as typeof omoSlimApi).getProviderCount,
enabled,
});
}
function useSaveGlobalConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: OmoGlobalConfig) => {
const jsonStr = JSON.stringify(input);
await configApi.setCommonConfigSnippet(snippetKey, jsonStr);
},
onSuccess: () => invalidateAll(queryClient),
});
}
function useReadLocalFile() {
return useMutation({
mutationFn: () => api.readLocalFile(),
@@ -116,10 +51,7 @@ function createOmoQueryHooks(
return {
keys,
useGlobalConfig,
useCurrentProviderId,
useProviderCount,
useSaveGlobalConfig,
useReadLocalFile,
useDisableCurrent,
};
@@ -135,16 +67,10 @@ const omoSlimHooks = createOmoQueryHooks("omo-slim", omoSlimApi);
export const omoKeys = omoHooks.keys;
export const omoSlimKeys = omoSlimHooks.keys;
export const useOmoGlobalConfig = omoHooks.useGlobalConfig;
export const useCurrentOmoProviderId = omoHooks.useCurrentProviderId;
export const useOmoProviderCount = omoHooks.useProviderCount;
export const useSaveOmoGlobalConfig = omoHooks.useSaveGlobalConfig;
export const useReadOmoLocalFile = omoHooks.useReadLocalFile;
export const useDisableCurrentOmo = omoHooks.useDisableCurrent;
export const useOmoSlimGlobalConfig = omoSlimHooks.useGlobalConfig;
export const useCurrentOmoSlimProviderId = omoSlimHooks.useCurrentProviderId;
export const useOmoSlimProviderCount = omoSlimHooks.useProviderCount;
export const useSaveOmoSlimGlobalConfig = omoSlimHooks.useSaveGlobalConfig;
export const useReadOmoSlimLocalFile = omoSlimHooks.useReadLocalFile;
export const useDisableCurrentOmoSlim = omoSlimHooks.useDisableCurrent;
+10
View File
@@ -1,6 +1,7 @@
export type ProviderCategory =
| "official" // 官方
| "cn_official" // 开源官方(原"国产官方")
| "cloud_provider" // 云服务商(AWS Bedrock 等)
| "aggregator" // 聚合网站
| "third_party" // 第三方供应商
| "custom" // 自定义
@@ -145,6 +146,10 @@ export interface ProviderMeta {
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
apiFormat?: "anthropic" | "openai_chat";
// Claude 认证字段名(仅 Claude 供应商使用)
// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
}
// Skill 同步方式
@@ -155,6 +160,11 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy";
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
export type ClaudeApiFormat = "anthropic" | "openai_chat";
// Claude 认证字段类型
// - "ANTHROPIC_AUTH_TOKEN": 大多数第三方/聚合供应商使用(默认)
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
// 主页面显示的应用配置
export interface VisibleApps {
claude: boolean;
+16 -103
View File
@@ -1,25 +1,7 @@
export interface OmoGlobalConfig {
id: string;
schemaUrl?: string;
sisyphusAgent?: Record<string, unknown>;
disabledAgents: string[];
disabledMcps: string[];
disabledHooks: string[];
disabledSkills: string[];
lsp?: Record<string, unknown>;
experimental?: Record<string, unknown>;
backgroundTask?: Record<string, unknown>;
browserAutomationEngine?: Record<string, unknown>;
claudeCode?: Record<string, unknown>;
otherFields?: Record<string, unknown>;
updatedAt: string;
}
export interface OmoLocalFileData {
agents?: Record<string, Record<string, unknown>>;
categories?: Record<string, Record<string, unknown>>;
otherFields?: Record<string, unknown>;
global: OmoGlobalConfig;
filePath: string;
lastModified?: string;
}
@@ -79,7 +61,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Oracle",
descKey: "omo.agentDesc.oracle",
tooltipKey: "omo.agentTooltip.oracle",
recommended: "gpt-5.3",
recommended: "gpt-5.2",
group: "sub",
},
{
@@ -87,7 +69,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Librarian",
descKey: "omo.agentDesc.librarian",
tooltipKey: "omo.agentTooltip.librarian",
recommended: "glm-4.7",
recommended: "gemini-3-flash",
group: "sub",
},
{
@@ -103,7 +85,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Multimodal-Looker",
descKey: "omo.agentDesc.multimodalLooker",
tooltipKey: "omo.agentTooltip.multimodalLooker",
recommended: "gemini-3-flash",
recommended: "kimi-k2.5",
group: "sub",
},
{
@@ -119,7 +101,7 @@ export const OMO_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Momus",
descKey: "omo.agentDesc.momus",
tooltipKey: "omo.agentTooltip.momus",
recommended: "gpt-5.3",
recommended: "gpt-5.2",
group: "sub",
},
{
@@ -144,7 +126,7 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
display: "Ultrabrain",
descKey: "omo.categoryDesc.ultrabrain",
tooltipKey: "omo.categoryTooltip.ultrabrain",
recommended: "claude-opus-4-6",
recommended: "gpt-5.3-codex",
},
{
key: "deep",
@@ -158,35 +140,35 @@ export const OMO_BUILTIN_CATEGORIES: OmoCategoryDef[] = [
display: "Artistry",
descKey: "omo.categoryDesc.artistry",
tooltipKey: "omo.categoryTooltip.artistry",
recommended: "claude-opus-4-6",
recommended: "gemini-3-pro",
},
{
key: "quick",
display: "Quick",
descKey: "omo.categoryDesc.quick",
tooltipKey: "omo.categoryTooltip.quick",
recommended: "gemini-3-flash",
recommended: "claude-haiku-4-5",
},
{
key: "unspecified-low",
display: "Unspecified Low",
descKey: "omo.categoryDesc.unspecifiedLow",
tooltipKey: "omo.categoryTooltip.unspecifiedLow",
recommended: "gemini-3-flash",
recommended: "claude-sonnet-4-6",
},
{
key: "unspecified-high",
display: "Unspecified High",
descKey: "omo.categoryDesc.unspecifiedHigh",
tooltipKey: "omo.categoryTooltip.unspecifiedHigh",
recommended: "gpt-5.3-codex",
recommended: "claude-opus-4-6",
},
{
key: "writing",
display: "Writing",
descKey: "omo.categoryDesc.writing",
tooltipKey: "omo.categoryTooltip.writing",
recommended: "claude-opus-4-6",
recommended: "gemini-3-flash",
},
];
@@ -337,7 +319,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Orchestrator",
descKey: "omo.slimAgentDesc.orchestrator",
tooltipKey: "omo.slimAgentTooltip.orchestrator",
recommended: "kimi-for-coding/k2p5",
recommended: "claude-opus-4-6",
group: "main",
},
{
@@ -345,7 +327,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Oracle",
descKey: "omo.slimAgentDesc.oracle",
tooltipKey: "omo.slimAgentTooltip.oracle",
recommended: "openai/gpt-5.2-codex",
recommended: "gpt-5.2",
group: "sub",
},
{
@@ -353,7 +335,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Librarian",
descKey: "omo.slimAgentDesc.librarian",
tooltipKey: "omo.slimAgentTooltip.librarian",
recommended: "openai/gpt-5.1-codex-mini",
recommended: "gemini-3-flash",
group: "sub",
},
{
@@ -361,7 +343,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Explorer",
descKey: "omo.slimAgentDesc.explorer",
tooltipKey: "omo.slimAgentTooltip.explorer",
recommended: "openai/gpt-5.1-codex-mini",
recommended: "grok-code-fast-1",
group: "sub",
},
{
@@ -369,7 +351,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Designer",
descKey: "omo.slimAgentDesc.designer",
tooltipKey: "omo.slimAgentTooltip.designer",
recommended: "kimi-for-coding/k2p5",
recommended: "gemini-3-pro",
group: "sub",
},
{
@@ -377,7 +359,7 @@ export const OMO_SLIM_BUILTIN_AGENTS: OmoAgentDef[] = [
display: "Fixer",
descKey: "omo.slimAgentDesc.fixer",
tooltipKey: "omo.slimAgentTooltip.fixer",
recommended: "openai/gpt-5.1-codex-mini",
recommended: "gpt-5.3-codex",
group: "sub",
},
];
@@ -406,75 +388,6 @@ export const OMO_SLIM_DISABLEABLE_HOOKS = [
export const OMO_SLIM_DEFAULT_SCHEMA_URL =
"https://raw.githubusercontent.com/alvinunreal/oh-my-opencode-slim/master/assets/oh-my-opencode-slim.schema.json";
export function mergeOmoConfigPreview(
global: OmoGlobalConfig | undefined,
agents: Record<string, Record<string, unknown>>,
categories: Record<string, Record<string, unknown>> | undefined,
otherFieldsStr: string,
options?: { slim?: boolean },
): Record<string, unknown> {
const result: Record<string, unknown> = {};
const isSlim = options?.slim ?? false;
if (global) {
if (global.schemaUrl) result["$schema"] = global.schemaUrl;
if (!isSlim) {
if (global.sisyphusAgent) result["sisyphus_agent"] = global.sisyphusAgent;
}
if (global.disabledAgents?.length)
result["disabled_agents"] = global.disabledAgents;
if (global.disabledMcps?.length)
result["disabled_mcps"] = global.disabledMcps;
if (global.disabledHooks?.length)
result["disabled_hooks"] = global.disabledHooks;
if (!isSlim) {
if (global.disabledSkills?.length)
result["disabled_skills"] = global.disabledSkills;
if (global.lsp) result["lsp"] = global.lsp;
if (global.experimental) result["experimental"] = global.experimental;
if (global.backgroundTask)
result["background_task"] = global.backgroundTask;
if (global.browserAutomationEngine)
result["browser_automation_engine"] = global.browserAutomationEngine;
if (global.claudeCode) result["claude_code"] = global.claudeCode;
}
if (global.otherFields) {
for (const [k, v] of Object.entries(global.otherFields)) {
result[k] = v;
}
}
}
if (Object.keys(agents).length > 0) result["agents"] = agents;
if (!isSlim && categories && Object.keys(categories).length > 0)
result["categories"] = categories;
try {
const other = parseOmoOtherFieldsObject(otherFieldsStr);
if (other) {
for (const [k, v] of Object.entries(other)) {
result[k] = v;
}
}
} catch {}
return result;
}
/** @deprecated Use mergeOmoConfigPreview with options.slim=true */
export function mergeOmoSlimConfigPreview(
global: OmoGlobalConfig | undefined,
agents: Record<string, Record<string, unknown>>,
otherFieldsStr: string,
): Record<string, unknown> {
return mergeOmoConfigPreview(global, agents, undefined, otherFieldsStr, {
slim: true,
});
}
export function buildOmoProfilePreview(
agents: Record<string, Record<string, unknown>>,
categories: Record<string, Record<string, unknown>> | undefined,
+31 -226
View File
@@ -3,86 +3,6 @@
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes } from "@/utils/textNormalization";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
): Record<string, any> => {
Object.entries(source).forEach(([key, value]) => {
if (isPlainObject(value)) {
if (!isPlainObject(target[key])) {
target[key] = {};
}
deepMerge(target[key], value);
} else {
// 直接覆盖非对象字段(数组/基础类型)
target[key] = value;
}
});
return target;
};
const deepRemove = (
target: Record<string, any>,
source: Record<string, any>,
) => {
Object.entries(source).forEach(([key, value]) => {
if (!(key in target)) return;
if (isPlainObject(value) && isPlainObject(target[key])) {
// 只移除完全匹配的嵌套属性
deepRemove(target[key], value);
if (Object.keys(target[key]).length === 0) {
delete target[key];
}
} else if (isSubset(target[key], value)) {
// 只有当值完全匹配时才删除
delete target[key];
}
});
};
const isSubset = (target: any, source: any): boolean => {
if (isPlainObject(source)) {
if (!isPlainObject(target)) return false;
return Object.entries(source).every(([key, value]) =>
isSubset(target[key], value),
);
}
if (Array.isArray(source)) {
if (!Array.isArray(target) || target.length !== source.length) return false;
return source.every((item, index) => isSubset(target[index], item));
}
return target === source;
};
// 深拷贝函数
const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
if (obj instanceof Object) {
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
};
export interface UpdateCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 验证JSON配置格式
export const validateJsonConfig = (
value: string,
@@ -102,69 +22,6 @@ export const validateJsonConfig = (
}
};
// 将通用配置片段写入/移除 settingsConfig
export const updateCommonConfigSnippet = (
jsonString: string,
snippetString: string,
enabled: boolean,
): UpdateCommonConfigResult => {
let config: Record<string, any>;
try {
config = jsonString ? JSON.parse(jsonString) : {};
} catch (err) {
return {
updatedConfig: jsonString,
error: "配置 JSON 解析失败,无法写入通用配置",
};
}
if (!snippetString.trim()) {
return {
updatedConfig: JSON.stringify(config, null, 2),
};
}
// 使用统一的验证函数
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
if (snippetError) {
return {
updatedConfig: JSON.stringify(config, null, 2),
error: snippetError,
};
}
const snippet = JSON.parse(snippetString) as Record<string, any>;
if (enabled) {
const merged = deepMerge(deepClone(config), snippet);
return {
updatedConfig: JSON.stringify(merged, null, 2),
};
}
const cloned = deepClone(config);
deepRemove(cloned, snippet);
return {
updatedConfig: JSON.stringify(cloned, null, 2),
};
};
// 检查当前配置是否已包含通用配置片段
export const hasCommonConfigSnippet = (
jsonString: string,
snippetString: string,
): boolean => {
try {
if (!snippetString.trim()) return false;
const config = jsonString ? JSON.parse(jsonString) : {};
const snippet = JSON.parse(snippetString);
if (!isPlainObject(snippet)) return false;
return isSubset(config, snippet);
} catch (err) {
return false;
}
};
// 读取配置中的 API Key(支持 Claude, Codex, Gemini
export const getApiKeyFromConfig = (
jsonString: string,
@@ -172,6 +29,16 @@ export const getApiKeyFromConfig = (
): string => {
try {
const config = JSON.parse(jsonString);
// 优先检查顶层 apiKey 字段(用于 Bedrock API Key 等预设)
if (
typeof config?.apiKey === "string" &&
config.apiKey &&
!config.apiKey.includes("${")
) {
return config.apiKey;
}
const env = config?.env;
if (!env) return "";
@@ -255,6 +122,12 @@ export const hasApiKeyField = (
): boolean => {
try {
const config = JSON.parse(jsonString);
// 检查顶层 apiKey 字段(用于 Bedrock API Key 等预设)
if (Object.prototype.hasOwnProperty.call(config, "apiKey")) {
return true;
}
const env = config?.env ?? {};
if (appType === "gemini") {
@@ -278,11 +151,22 @@ export const hasApiKeyField = (
export const setApiKeyInConfig = (
jsonString: string,
apiKey: string,
options: { createIfMissing?: boolean; appType?: string } = {},
options: {
createIfMissing?: boolean;
appType?: string;
apiKeyField?: string;
} = {},
): string => {
const { createIfMissing = false, appType } = options;
const { createIfMissing = false, appType, apiKeyField } = options;
try {
const config = JSON.parse(jsonString);
// 优先检查顶层 apiKey 字段(用于 Bedrock API Key 等预设)
if (Object.prototype.hasOwnProperty.call(config, "apiKey")) {
config.apiKey = apiKey;
return JSON.stringify(config, null, 2);
}
if (!config.env) {
if (!createIfMissing) return jsonString;
config.env = {};
@@ -313,13 +197,13 @@ export const setApiKeyInConfig = (
return JSON.stringify(config, null, 2);
}
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段)
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 指定的字段)
if ("ANTHROPIC_AUTH_TOKEN" in env) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
} else if ("ANTHROPIC_API_KEY" in env) {
env.ANTHROPIC_API_KEY = apiKey;
} else if (createIfMissing) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey;
} else {
return jsonString;
}
@@ -329,85 +213,6 @@ export const setApiKeyInConfig = (
}
};
// ========== TOML Config Utilities ==========
export interface UpdateTomlCommonConfigResult {
updatedConfig: string;
error?: string;
}
// 保存之前的通用配置片段,用于替换操作
let previousCommonSnippet = "";
// 将通用配置片段写入/移除 TOML 配置
export const updateTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
enabled: boolean,
): UpdateTomlCommonConfigResult => {
if (!snippetString.trim()) {
// 如果片段为空,直接返回原始配置
return {
updatedConfig: tomlString,
};
}
if (enabled) {
// 添加通用配置
// 先移除旧的通用配置(如果有)
let updatedConfig = tomlString;
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
updatedConfig = tomlString.replace(previousCommonSnippet, "");
}
// 在文件末尾添加新的通用配置
// 确保有适当的换行
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
updatedConfig =
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
// 保存当前通用配置片段
previousCommonSnippet = snippetString;
return {
updatedConfig: updatedConfig.trim() + "\n",
};
} else {
// 移除通用配置
if (tomlString.includes(snippetString)) {
const updatedConfig = tomlString.replace(snippetString, "");
// 清理多余的空行
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
// 清空保存的状态
previousCommonSnippet = "";
return {
updatedConfig: cleaned ? cleaned + "\n" : "",
};
}
return {
updatedConfig: tomlString,
};
}
};
// 检查 TOML 配置是否已包含通用配置片段
export const hasTomlCommonConfigSnippet = (
tomlString: string,
snippetString: string,
): boolean => {
if (!snippetString.trim()) return false;
// 简单检查配置是否包含片段内容
// 去除空白字符后比较,避免格式差异影响
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
return normalizeWhitespace(tomlString).includes(
normalizeWhitespace(snippetString),
);
};
// ========== Codex base_url utils ==========
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
+2 -1
View File
@@ -339,8 +339,9 @@ describe("SettingsPage Component", () => {
});
fireEvent.click(screen.getByText("settings.tabAdvanced"));
fireEvent.click(screen.getByText("settings.advanced.data.title"));
fireEvent.click(screen.getByText("settings.advanced.cloudSync.title"));
expect(screen.getByText("webdav-sync-section:none")).toBeInTheDocument();
fireEvent.click(screen.getByText("settings.advanced.data.title"));
// 有文件时,点击导入按钮执行 importConfig
fireEvent.click(
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { providerPresets } from "@/config/claudeProviderPresets";
describe("AWS Bedrock Provider Presets", () => {
const bedrockAksk = providerPresets.find(
(p) => p.name === "AWS Bedrock (AKSK)",
);
it("should include AWS Bedrock (AKSK) preset", () => {
expect(bedrockAksk).toBeDefined();
});
it("AKSK preset should have required AWS env variables", () => {
const env = (bedrockAksk!.settingsConfig as any).env;
expect(env).toHaveProperty("AWS_ACCESS_KEY_ID");
expect(env).toHaveProperty("AWS_SECRET_ACCESS_KEY");
expect(env).toHaveProperty("AWS_REGION");
expect(env).toHaveProperty("CLAUDE_CODE_USE_BEDROCK", "1");
});
it("AKSK preset should have template values for AWS credentials", () => {
expect(bedrockAksk!.templateValues).toBeDefined();
expect(bedrockAksk!.templateValues!.AWS_ACCESS_KEY_ID).toBeDefined();
expect(bedrockAksk!.templateValues!.AWS_SECRET_ACCESS_KEY).toBeDefined();
expect(bedrockAksk!.templateValues!.AWS_REGION).toBeDefined();
expect(bedrockAksk!.templateValues!.AWS_REGION.editorValue).toBe(
"us-west-2",
);
});
it("AKSK preset should have correct base URL template", () => {
const env = (bedrockAksk!.settingsConfig as any).env;
expect(env.ANTHROPIC_BASE_URL).toContain("bedrock-runtime");
expect(env.ANTHROPIC_BASE_URL).toContain("${AWS_REGION}");
});
it("AKSK preset should have cloud_provider category", () => {
expect(bedrockAksk!.category).toBe("cloud_provider");
});
it("AKSK preset should have Bedrock model as default", () => {
const env = (bedrockAksk!.settingsConfig as any).env;
expect(env.ANTHROPIC_MODEL).toContain("anthropic.claude");
});
const bedrockApiKey = providerPresets.find(
(p) => p.name === "AWS Bedrock (API Key)",
);
it("should include AWS Bedrock (API Key) preset", () => {
expect(bedrockApiKey).toBeDefined();
});
it("API Key preset should have apiKey field and AWS env variables", () => {
const config = bedrockApiKey!.settingsConfig as any;
expect(config).toHaveProperty("apiKey", "");
expect(config.env).toHaveProperty("AWS_REGION");
expect(config.env).toHaveProperty("CLAUDE_CODE_USE_BEDROCK", "1");
});
it("API Key preset should NOT have AKSK env variables", () => {
const env = (bedrockApiKey!.settingsConfig as any).env;
expect(env).not.toHaveProperty("AWS_ACCESS_KEY_ID");
expect(env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY");
});
it("API Key preset should have template values for region only", () => {
expect(bedrockApiKey!.templateValues).toBeDefined();
expect(bedrockApiKey!.templateValues!.AWS_REGION).toBeDefined();
expect(bedrockApiKey!.templateValues!.AWS_REGION.editorValue).toBe(
"us-west-2",
);
});
it("API Key preset should have cloud_provider category", () => {
expect(bedrockApiKey!.category).toBe("cloud_provider");
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
opencodeProviderPresets,
opencodeNpmPackages,
OPENCODE_PRESET_MODEL_VARIANTS,
} from "@/config/opencodeProviderPresets";
describe("AWS Bedrock OpenCode Provider Presets", () => {
it("should include @ai-sdk/amazon-bedrock in npm packages", () => {
const bedrockPkg = opencodeNpmPackages.find(
(p) => p.value === "@ai-sdk/amazon-bedrock",
);
expect(bedrockPkg).toBeDefined();
expect(bedrockPkg!.label).toBe("Amazon Bedrock");
});
it("should include Bedrock model variants", () => {
const variants = OPENCODE_PRESET_MODEL_VARIANTS["@ai-sdk/amazon-bedrock"];
expect(variants).toBeDefined();
expect(variants.length).toBeGreaterThan(0);
const opusModel = variants.find((v) =>
v.id.includes("anthropic.claude-opus-4-6"),
);
expect(opusModel).toBeDefined();
});
const bedrockPreset = opencodeProviderPresets.find(
(p) => p.name === "AWS Bedrock",
);
it("should include AWS Bedrock preset", () => {
expect(bedrockPreset).toBeDefined();
});
it("Bedrock preset should use @ai-sdk/amazon-bedrock npm package", () => {
expect(bedrockPreset!.settingsConfig.npm).toBe(
"@ai-sdk/amazon-bedrock",
);
});
it("Bedrock preset should have region in options", () => {
expect(bedrockPreset!.settingsConfig.options).toHaveProperty("region");
});
it("Bedrock preset should have cloud_provider category", () => {
expect(bedrockPreset!.category).toBe("cloud_provider");
});
it("Bedrock preset should have template values for AWS credentials", () => {
expect(bedrockPreset!.templateValues).toBeDefined();
expect(bedrockPreset!.templateValues!.region).toBeDefined();
expect(bedrockPreset!.templateValues!.region.editorValue).toBe(
"us-west-2",
);
expect(bedrockPreset!.templateValues!.accessKeyId).toBeDefined();
expect(bedrockPreset!.templateValues!.secretAccessKey).toBeDefined();
});
it("Bedrock preset should include Claude models", () => {
const models = bedrockPreset!.settingsConfig.models;
expect(models).toBeDefined();
const modelIds = Object.keys(models!);
expect(
modelIds.some((id) => id.includes("anthropic.claude")),
).toBe(true);
});
});

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