mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4a1ea907 | |||
| 58b7dc32b3 | |||
| 65ccabd5ad | |||
| a3a81806f8 | |||
| 5104045ffb | |||
| 7898096de3 | |||
| cc235c6c63 |
@@ -23,4 +23,3 @@ nul
|
||||
flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
.worktrees/
|
||||
|
||||
+1
-204
@@ -7,212 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.11.1] - 2026-02-28
|
||||
|
||||
### Hotfix Release
|
||||
|
||||
This release reverts the Partial Key-Field Merging architecture introduced in v3.11.0, restoring the proven "full config overwrite + Common Config Snippet" mechanism, and fixes several UI and platform compatibility issues.
|
||||
|
||||
**Stats**: 8 commits | 52 files changed | +3,948 insertions | -1,411 deletions
|
||||
|
||||
### Reverted
|
||||
|
||||
- **Restore Full Config Overwrite + Common Config Snippet** (revert 992dda5c): Reverted the partial key-field merging refactoring from v3.11.0 due to critical issues — non-whitelisted custom fields were lost during provider switching, backfill permanently stripped non-key fields from the database, and the whitelist required constant maintenance. Restores full config snapshot write, Common Config Snippet UI and backend commands, and 6 frontend components/hooks
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy Panel Layout**: Moved proxy on/off toggle from accordion header into panel content area, placed directly above app takeover options, ensuring users see takeover configuration immediately after enabling the proxy
|
||||
- **Manual Import for OpenCode/OpenClaw**: Removed auto-import on startup; empty state now shows an "Import Current Config" button, consistent with Claude/Codex/Gemini behavior
|
||||
|
||||
### Fixed
|
||||
|
||||
- **"Follow System" Theme Not Auto-Updating**: Delegated to Tauri's native theme tracking (`set_window_theme(None)`) so the WebView's `prefers-color-scheme` media query stays in sync with OS theme changes
|
||||
- **Compact Mode Cannot Exit**: Restored `flex-1` on `toolbarRef` so `useAutoCompact`'s exit condition triggers correctly based on available width instead of content width
|
||||
- **Proxy Takeover Toast Shows {{app}}**: Added missing `app` interpolation parameter to i18next `t()` calls for proxy takeover enabled/disabled messages
|
||||
- **Windows Protocol Handler Side Effects**: Disabled environment check and one-click install on Windows to prevent unintended protocol handler registration
|
||||
|
||||
---
|
||||
|
||||
## [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 (⚠️ Breaking, reverted in v3.11.1)**: Provider switching now uses partial key-field merging instead of full config overwrite, preserving user's non-provider settings (plugins, MCP, permissions). The "Common Config Snippet" feature has been removed as it is no longer needed. Removes 6 frontend files and ~150 lines of backend dead code (#1098)
|
||||
- **Manual Import**: Replaced auto-import on startup with manual “Import Current Config” button in empty state, reducing ~47 lines of startup code
|
||||
- **OMO Variant Parameterization**: Eliminated ~250 lines of OMO/OMO Slim code duplication via `OmoVariant` struct with STANDARD/SLIM constants
|
||||
- **OMO Common Config Removal**: Removed the two-layer merge system for OMO common config (-1,733 lines across 21 files)
|
||||
|
||||
#### Code Quality
|
||||
|
||||
- **ProviderForm Decomposition**: Extracted ProviderForm.tsx from 2,227 lines to 1,526 lines by splitting into 5 focused modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState)
|
||||
- **Shared MCP/Skills Components**: Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to eliminate duplication across MCP and Skills panels
|
||||
- **OpenClaw TanStack Query Migration**: Migrated Env, Tools, and AgentsDefaults panels from manual useState/useEffect to centralized TanStack Query hooks
|
||||
|
||||
#### Settings Layout
|
||||
|
||||
- **Proxy Tab**: Split Advanced tab into dedicated Proxy tab (local proxy, failover, rectifiers, global outbound proxy); moved pricing config to Usage dashboard as collapsible accordion. SettingsPage reduced from ~716 to ~426 lines with 5-tab layout: General | Proxy | Advanced | Usage | About
|
||||
- **Data Section Split**: Split data accordion into Import/Export and Cloud Sync sections for better discoverability
|
||||
|
||||
#### Terminal & Config
|
||||
|
||||
- **Unified Terminal Selection**: Consolidated terminal preference to global settings; added WezTerm support and terminal name mapping (iterm2 → iterm)
|
||||
- **OpenClaw Agents Panel**: Primary model field set to read-only; detailed model fields (context window, max tokens, reasoning, cost) moved to advanced options
|
||||
- **Claude Model Update**: Updated Claude model references from 4.5 to 4.6 across all provider presets
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Critical
|
||||
|
||||
- **Windows Home Dir Regression**: Restored default home directory resolution on Windows to prevent providers/settings “disappearing” when `HOME` env var differs from the real user profile directory (Git/MSYS environments); auto-detects v3.10.3 legacy database location
|
||||
- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent EGL initialization failure causing blank screen on startup
|
||||
- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to OpenAI Chat Completions endpoints, fixing request failures for Nvidia and other `apiFormat=”openai_chat”` providers
|
||||
- **Health Check Auth Mode**: Health check now respects provider's auth_mode setting instead of always using x-api-key header
|
||||
|
||||
#### Provider & Preset
|
||||
|
||||
- **OpenClaw /v1 Prefix**: Removed /v1 prefix from OpenClaw anthropic-messages presets to prevent double path (/v1/v1/messages) with Anthropic SDK auto-append
|
||||
- **Opus Pricing**: Corrected Opus pricing from $15/$75 to $5/$25 and upgraded model ID to claude-opus-4-6
|
||||
- **AIGoCode URLs**: Unified API base URL to https://api.aigocode.com across all apps; removed trailing /v1 suffix
|
||||
- **Zhipu GLM**: Removed outdated partner status from Claude, OpenCode, and OpenClaw presets
|
||||
- **API Key Visibility**: Restored API Key input field when creating new Claude providers (was incorrectly hidden for non-cloud_provider categories)
|
||||
|
||||
#### OMO / OMO Slim
|
||||
|
||||
- **OMO Slim Category Checks**: Added missing omo-slim category checks across add/form/mutation paths
|
||||
- **OMO Slim Cache Invalidation**: Invalidate OMO Slim query cache after provider mutations to prevent stale UI state
|
||||
- **OMO Recommended Models**: Synced agent/category recommended models with upstream sources; fixed provider/model format to pure model IDs
|
||||
- **OMO Fill Feedback**: Added toast feedback when “Fill Recommended” button silently fails
|
||||
- **OMO Last-Provider Restriction**: Removed last-provider deletion restriction for OMO/OMO Slim plugins
|
||||
- **OpenCode Model Validation**: Reject saving OpenCode providers without at least one configured model
|
||||
|
||||
#### OpenClaw
|
||||
|
||||
- **OpenClaw P0-P3 Fixes**: Fixed 25 missing i18n keys, replaced key={index} with stable crypto.randomUUID(), excluded openclaw from ProxyToggle/FailoverToggle, added deep link merge_additive_config(), unified serde(flatten) naming, added directory existence checks, removed dead code, added duplicate key validation
|
||||
- **OpenClaw Robustness**: Fixed EnvPanel visibleKeys using entry key names instead of array indices; added NaN guards; validated provider ID and model before import
|
||||
- **OpenClaw i18n Dedup**: Merged duplicate openclaw i18n keys to restore provider form translations
|
||||
|
||||
#### Platform
|
||||
|
||||
- **Window Flash**: Prevented window flicker on silent startup (Windows)
|
||||
- **Title Bar Theme**: Title bar now follows dark/light mode theme changes
|
||||
- **Skills Path Separator**: Fixed path separator matching for skill installation status on Windows (supports both `/` and `\`)
|
||||
- **WSL Conditional Compilation**: Added `#[cfg(target_os = “windows”)]` to WSL helper functions to eliminate dead_code warnings on non-Windows platforms
|
||||
|
||||
#### UI
|
||||
|
||||
- **Toolbar Clipping**: Removed toolbar height limit that was clipping AppSwitcher
|
||||
- **Update Badge**: Show update badge instead of green check when a newer version is available
|
||||
- **Session Button Visibility**: Only show Session Manager button for Claude and Codex apps
|
||||
- **Directory Spacing**: Added vertical spacing between directory setting sections
|
||||
- **Dark Mode Cards**: Unified SQL import/export card styling in dark mode
|
||||
- **OpenClaw Scroll**: Enabled scrolling for OpenClaw configuration panel content
|
||||
|
||||
#### i18n & Localization
|
||||
|
||||
- **Session Manager i18n**: Replaced hardcoded Chinese strings with i18n keys for relative time, role labels, and UI elements
|
||||
- **OpenClaw Default Model Label**: Renamed “Enable/Default” to “Set as Default / Current Default” with wider button
|
||||
- **Daily Memory Sort**: Sort daily memory files by filename date (YYYY-MM-DD.md) instead of modification time
|
||||
- **Backup Name i18n**: Use local time for backup file names
|
||||
|
||||
#### Other
|
||||
|
||||
- **Skill Doc URL**: Use actual branch from download_repo for documentation URL; switched from /tree/ to /blob/ pointing to SKILL.md
|
||||
- **OpenCode Install Detection**: Added install.sh priority paths (OPENCODE_INSTALL_DIR > XDG_BIN_DIR > ~/bin > ~/.opencode/bin) with path dedup and cross-platform executable candidates
|
||||
- **Provider Auto-Import**: Removed auto-import side effect from useProvidersQuery queryFn; users now trigger import manually via empty state button
|
||||
- **Manual Backup Validation**: Treat missing database file as error during manual backup to prevent false success toast
|
||||
|
||||
### Performance
|
||||
|
||||
- **Session Panel Loading**: Parallel directory scanning and head-tail JSONL reading for Codex, OpenClaw, and OpenCode session providers
|
||||
- **Query Cache Cleanup**: Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Sponsors**: Added/updated SSSAiCode, Crazyrouter, AICoding, Right Code, and MiniMax sponsor entries across all README languages
|
||||
- **User Manual**: Added user manual documentation (#979)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **Pre-Release Cleanup**: Removed debug logs, fixed clippy warnings, added missing Japanese translations, and formatted code
|
||||
- **UI Exclusions**: Hidden MCP, Skills, proxy/pricing, stream check, and model test panels for OpenClaw where not applicable
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -80,7 +80,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
|
||||
## Features
|
||||
|
||||
### Current Version: v3.11.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.11.1-en.md)
|
||||
### Current Version: v3.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
@@ -156,7 +156,6 @@ 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)
|
||||
|
||||
@@ -156,7 +156,6 @@ 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/エラー/トレイを含む中国語・英語・日本語ローカライズ
|
||||
|
||||
@@ -157,7 +157,6 @@ 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、错误、托盘)
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
# 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 (⚠️ Breaking Change)**: Provider switching now only replaces provider-related fields, preserving all other settings; the "Common Config Snippet" feature has been removed
|
||||
- **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 (⚠️ Breaking Change)
|
||||
|
||||
Provider switching now uses partial key-field merging instead of full config overwrite (#1098).
|
||||
|
||||
**Before**: Switching providers overwrote the entire `settings_config` to the live config file. This meant that any non-provider settings the user manually added to the live file (plugins, MCP config, permissions, etc.) would be lost on every switch. To work around this, previous versions offered a "Common Config Snippet" feature that let users define shared config to be merged on every switch.
|
||||
|
||||
**After**: Switching providers now only replaces provider-related key-values (API keys, endpoints, models, etc.), leaving all other settings intact. The "Common Config Snippet" feature is therefore no longer needed and has been removed.
|
||||
|
||||
**Impact & Migration**:
|
||||
- If you **didn't use** Common Config Snippets, this change is fully transparent — switching just works better now
|
||||
- If you **used** Common Config Snippets to preserve custom settings (MCP config, permissions, etc.), those settings are now automatically preserved during switches — no action needed
|
||||
- If you used Common Config Snippets for other purposes (e.g., injecting extra config on every switch), please manually add those settings to your live config file after upgrading
|
||||
|
||||
This refactoring removed 6 frontend files (3 components + 3 hooks) 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.
|
||||
- **⚠️ Common Config Snippet feature has been removed**: Since provider switching now uses partial key-field merging (only replacing API keys, endpoints, models, etc.), user's other settings are automatically preserved, making Common Config Snippets unnecessary. See the "Architecture Improvements" section above for migration details.
|
||||
- **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` |
|
||||
@@ -1,302 +0,0 @@
|
||||
# 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)。
|
||||
|
||||
**変更前**: プロバイダーを切り替えると、`settings_config` 全体がライブ設定ファイルに上書きされていました。つまり、ユーザーがライブファイルに手動で追加した非プロバイダー設定(プラグイン設定、MCP 設定、権限設定など)は、切り替えのたびに失われていました。この問題を補うため、以前のバージョンでは「共通設定スニペット」機能を提供し、毎回の切り替え時にマージされる共通設定を定義できました。
|
||||
|
||||
**変更後**: プロバイダー切り替え時に、プロバイダー関連のキー値(API キー、エンドポイント、モデルなど)のみが置換され、その他の設定はそのまま保持されます。そのため「共通設定スニペット」機能は不要となり、削除されました。
|
||||
|
||||
**影響と移行**:
|
||||
- 共通設定スニペットを**使用していなかった**場合、この変更は完全に透過的で、切り替え体験が向上するだけです
|
||||
- カスタム設定(MCP 設定、権限など)を保持するために共通設定スニペットを**使用していた**場合、それらの設定は切り替え時に自動的に保持されるようになり、追加の操作は不要です
|
||||
- 共通設定スニペットを他の目的(切り替え時に追加設定を注入するなど)で使用していた場合は、アップグレード後にライブ設定ファイルに手動で設定を追加してください
|
||||
|
||||
このリファクタリングにより、フロントエンドファイル 6 つ(コンポーネント 3 つ + hooks 3 つ)と約 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 をインストールする必要があります。
|
||||
- **⚠️ 共通設定スニペット機能は削除されました**: プロバイダー切り替えが部分キーフィールドマージ(API キー、エンドポイント、モデルなどのみ置換)に変更されたため、ユーザーのその他の設定は自動的に保持され、共通設定スニペットは不要になりました。移行の詳細は上記「アーキテクチャ改善」セクションを参照してください。
|
||||
- **自動インポートは手動に変更されました**: 起動時に外部設定を自動インポートしなくなりました。必要に応じて「現在の設定をインポート」を手動でクリックしてください。
|
||||
- **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` |
|
||||
@@ -1,302 +0,0 @@
|
||||
# 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)。
|
||||
|
||||
**变更前**:切换供应商时,整个 `settings_config` 会覆写到 live 配置文件。这意味着用户在 live 文件中手动添加的非供应商设置(插件配置、MCP 配置、权限设置等)会在每次切换时丢失。为了弥补这个问题,之前版本提供了"通用配置片段"功能,让用户定义每次切换时都会合并的公共配置。
|
||||
|
||||
**变更后**:切换供应商时,仅替换供应商相关的键值(API Key、端点、模型等),用户的其余设置完整保留。因此"通用配置片段"功能不再需要,已被移除。
|
||||
|
||||
**影响与迁移**:
|
||||
- 如果你之前**没有使用**通用配置片段功能,此变更对你完全透明,切换体验只会更好
|
||||
- 如果你之前**使用了**通用配置片段功能来保留自定义设置(如 MCP 配置、权限等),升级后这些设置会在切换时自动保留,无需额外操作
|
||||
- 如果你利用通用配置片段做其他用途(如在切换时注入额外配置),请在升级后手动将这些配置写入 live 配置文件中
|
||||
|
||||
此次重构删除了 6 个前端文件(3 个组件 + 3 个 hooks)、约 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 才能使用相关功能。
|
||||
- **⚠️ 通用配置片段功能已移除**:由于供应商切换改为部分键值合并(仅替换 API Key、端点、模型等字段),用户的其余设置会自动保留,"通用配置片段"功能不再需要。详见上方"架构改进"章节的迁移说明。
|
||||
- **自动导入已改为手动**:启动时不再自动导入外部配置,请在需要时手动点击"导入当前配置"。
|
||||
- **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 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### 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` |
|
||||
@@ -1,122 +0,0 @@
|
||||
# CC Switch v3.11.1
|
||||
|
||||
> Revert Partial Key-Field Merging, Restore Common Config Snippet & Bug Fixes
|
||||
|
||||
**[中文版 →](release-note-v3.11.1-zh.md) | [日本語版 →](release-note-v3.11.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.11.1 is a hotfix release that reverts the **Partial Key-Field Merging** architecture introduced in v3.11.0, restoring the proven "**full config overwrite + Common Config Snippet**" mechanism. It also includes several UI and platform compatibility fixes.
|
||||
|
||||
**Release Date**: 2026-02-28
|
||||
|
||||
**Update Scale**: 8 commits | 52 files changed | +3,948 / -1,411 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Restore Full Config Overwrite + Common Config Snippet**: Reverted partial key-field merging due to critical data loss issues; restores full config snapshot write and Common Config Snippet UI
|
||||
- **Proxy Panel Improvements**: Proxy toggle moved into panel body for better discoverability of takeover options
|
||||
- **Theme & Compact Mode Fixes**: "Follow System" theme now auto-updates; compact mode exit works correctly
|
||||
- **Windows Compatibility**: Disabled env check and one-click install to prevent protocol handler side effects
|
||||
|
||||
---
|
||||
|
||||
## Reverted
|
||||
|
||||
### Restore Full Config Overwrite + Common Config Snippet
|
||||
|
||||
Reverted the partial key-field merging refactoring introduced in v3.11.0 (revert 992dda5c).
|
||||
|
||||
**Why reverted**: The partial key-field merging approach had three critical issues:
|
||||
1. **Data loss on switch**: Non-whitelisted custom fields were silently dropped during provider switching
|
||||
2. **Permanent backfill stripping**: Backfill permanently removed non-key fields from the database, causing irreversible data loss
|
||||
3. **Maintenance burden**: The whitelist of "key fields" required constant maintenance as new config keys were added
|
||||
|
||||
**What's restored**:
|
||||
- Full config snapshot write on provider switch (predictable, complete overwrite)
|
||||
- Common Config Snippet UI and backend commands
|
||||
- 6 frontend components/hooks (3 components + 3 hooks)
|
||||
|
||||
**Migration**:
|
||||
- If you upgraded to v3.11.0 and your providers lost custom fields, re-import your config or manually re-add the missing fields
|
||||
- Common Config Snippet is available again — use it to define shared config that should persist across provider switches
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
- **Proxy Panel Layout**: Moved proxy on/off toggle from accordion header into panel content area, placed directly above app takeover options. This ensures users see takeover configuration immediately after enabling the proxy, avoiding the common mistake of enabling the proxy without configuring takeover
|
||||
- **Manual Import for OpenCode/OpenClaw**: Removed auto-import on startup; empty state now shows an "Import Current Config" button, consistent with Claude/Codex/Gemini behavior
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
- **"Follow System" Theme Not Auto-Updating**: Delegated to Tauri's native theme tracking (`set_window_theme(None)`) so the WebView's `prefers-color-scheme` media query stays in sync with OS theme changes
|
||||
- **Compact Mode Cannot Exit**: Restored `flex-1` on `toolbarRef` so `useAutoCompact`'s exit condition triggers correctly based on available width instead of content width
|
||||
- **Proxy Takeover Toast Shows {{app}}**: Added missing `app` interpolation parameter to i18next `t()` calls for proxy takeover enabled/disabled messages
|
||||
- **Windows Protocol Handler Side Effects**: Disabled environment check and one-click install on Windows to prevent unintended protocol handler registration
|
||||
|
||||
---
|
||||
|
||||
## Notes & Considerations
|
||||
|
||||
- **Common Config Snippet is back**: If you relied on this feature in v3.10.x and earlier, it works the same way again. Define shared config that should persist across all provider switches.
|
||||
- **v3.11.0 Partial Key-Field Merging users**: If you noticed missing config fields after switching providers in v3.11.0, re-import your config to restore them.
|
||||
|
||||
---
|
||||
|
||||
## 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.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.11.1-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | -------------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.11.1-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.11.1-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` |
|
||||
@@ -1,122 +0,0 @@
|
||||
# CC Switch v3.11.1
|
||||
|
||||
> 部分キーフィールドマージの撤回、共通設定スニペットの復元とバグ修正
|
||||
|
||||
**[中文版 →](release-note-v3.11.1-zh.md) | [English →](release-note-v3.11.1-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.11.1 は修正リリースです。v3.11.0 で導入された**部分キーフィールドマージ**アーキテクチャを撤回し、実績のある「**完全設定上書き + 共通設定スニペット**」メカニズムを復元しました。また、複数の UI とプラットフォーム互換性の問題を修正しています。
|
||||
|
||||
**リリース日**: 2026-02-28
|
||||
|
||||
**更新規模**: 8 commits | 52 files changed | +3,948 / -1,411 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **完全設定上書き + 共通設定スニペットの復元**: 重大なデータ損失問題のため部分キーフィールドマージを撤回、完全設定スナップショット書き込みと共通設定スニペット UI を復元
|
||||
- **プロキシパネルの改善**: プロキシトグルをパネル本体に移動し、テイクオーバーオプションの発見性を向上
|
||||
- **テーマとコンパクトモードの修正**: 「システムに従う」テーマが正しく自動更新、コンパクトモードの終了が正常に動作
|
||||
- **Windows 互換性**: プロトコルハンドラーの副作用を防ぐため、環境チェックとワンクリックインストールを無効化
|
||||
|
||||
---
|
||||
|
||||
## 撤回
|
||||
|
||||
### 完全設定上書き + 共通設定スニペットの復元
|
||||
|
||||
v3.11.0 で導入された部分キーフィールドマージリファクタリングを撤回しました(revert 992dda5c)。
|
||||
|
||||
**撤回理由**: 部分キーフィールドマージのアプローチには3つの重大な問題がありました:
|
||||
1. **切り替え時のデータ損失**: ホワイトリストにないカスタムフィールドがプロバイダー切り替え時にサイレントに破棄された
|
||||
2. **バックフィルによる永続的な剥離**: バックフィル操作がデータベースから非キーフィールドを永続的に削除し、不可逆なデータ損失を引き起こした
|
||||
3. **メンテナンス負担**: 「キーフィールド」のホワイトリストは新しい設定キーが追加されるたびに継続的なメンテナンスが必要
|
||||
|
||||
**復元された内容**:
|
||||
- プロバイダー切り替え時の完全設定スナップショット書き込み(予測可能な完全上書き)
|
||||
- 共通設定スニペット UI およびバックエンドコマンド
|
||||
- 6つのフロントエンドファイル(コンポーネント 3つ + hooks 3つ)
|
||||
|
||||
**移行ガイド**:
|
||||
- v3.11.0 にアップグレードしてプロバイダーのカスタムフィールドが失われた場合は、設定を再インポートするか、欠落したフィールドを手動で追加してください
|
||||
- 共通設定スニペット機能が再び利用可能です — プロバイダー切り替え時に保持すべき共有設定を定義するために使用してください
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
- **プロキシパネルレイアウト**: プロキシのオン/オフトグルをアコーディオンヘッダーからパネルのコンテンツエリアに移動し、アプリテイクオーバーオプションの直上に配置。プロキシを有効にした後すぐにテイクオーバー設定が見えるようになり、「プロキシだけ有効にしてテイクオーバーを設定しない」というよくある誤操作を防止
|
||||
- **OpenCode/OpenClaw の手動インポート**: 起動時の自動インポートを削除。空の状態ページに「現在の設定をインポート」ボタンを表示し、Claude/Codex/Gemini と同じ動作に統一
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
- **「システムに従う」テーマが自動更新されない**: Tauri のネイティブテーマ追跡(`set_window_theme(None)`)に委譲し、WebView の `prefers-color-scheme` メディアクエリが OS テーマの変更に同期するように修正
|
||||
- **コンパクトモードを終了できない**: `toolbarRef` の `flex-1` を復元し、`useAutoCompact` の終了条件がコンテンツ幅ではなく利用可能な幅に基づいて正しくトリガーされるように修正
|
||||
- **プロキシテイクオーバー Toast に {{app}} が表示される**: プロキシテイクオーバーの有効/無効メッセージの i18next `t()` 呼び出しに欠落していた `app` 補間パラメータを追加
|
||||
- **Windows プロトコルハンドラーの副作用**: 意図しないプロトコルハンドラー登録を防ぐため、Windows で環境チェックとワンクリックインストールを無効化
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
- **共通設定スニペットが復活しました**: v3.10.x 以前でこの機能を使用していた場合、同じ方法で動作します。プロバイダー切り替え時に保持すべき共有設定を定義するために使用してください。
|
||||
- **v3.11.0 部分キーフィールドマージユーザーの方へ**: v3.11.0 でプロバイダー切り替え後に設定フィールドが欠落していた場合は、設定を再インポートして復元してください。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[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.1-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.11.1-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.11.1-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.11.1-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` |
|
||||
@@ -1,122 +0,0 @@
|
||||
# CC Switch v3.11.1
|
||||
|
||||
> 回退部分键值合并、恢复通用配置片段与多项修复
|
||||
|
||||
**[English →](release-note-v3.11.1-en.md) | [日本語版 →](release-note-v3.11.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.11.1 是一个修复版本,回退了 v3.11.0 中引入的**部分键值合并**架构,恢复经过验证的「**全量配置覆写 + 通用配置片段**」机制,同时修复了多个 UI 和平台兼容性问题。
|
||||
|
||||
**发布日期**:2026-02-28
|
||||
|
||||
**更新规模**:8 commits | 52 files changed | +3,948 / -1,411 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **恢复全量配置覆写 + 通用配置片段**:因关键数据丢失问题回退部分键值合并,恢复完整配置快照写入和通用配置片段 UI
|
||||
- **代理面板交互优化**:代理开关移入面板内部,接管选项一目了然
|
||||
- **主题与紧凑模式修复**:「跟随系统」主题现可正确自动更新,紧凑模式退出恢复正常
|
||||
- **Windows 兼容性**:禁用环境检查和一键安装,防止协议处理程序副作用
|
||||
|
||||
---
|
||||
|
||||
## 回退
|
||||
|
||||
### 恢复全量配置覆写 + 通用配置片段
|
||||
|
||||
回退了 v3.11.0 中引入的部分键值合并重构(revert 992dda5c)。
|
||||
|
||||
**回退原因**:部分键值合并方案存在三个关键缺陷:
|
||||
1. **切换时数据丢失**:非白名单的自定义字段在供应商切换时被静默丢弃
|
||||
2. **回填永久剥离**:回填操作永久移除数据库中的非键字段,造成不可逆的数据丢失
|
||||
3. **维护成本高**:「键字段」白名单需要随新配置项不断维护,容易遗漏
|
||||
|
||||
**恢复的内容**:
|
||||
- 供应商切换时的完整配置快照写入(可预测的全量覆写)
|
||||
- 通用配置片段 UI 及后端命令
|
||||
- 6 个前端文件(3 个组件 + 3 个 hooks)
|
||||
|
||||
**迁移说明**:
|
||||
- 如果你在 v3.11.0 中切换供应商后丢失了自定义字段,请重新导入配置或手动补回缺失的字段
|
||||
- 通用配置片段功能已恢复——用它来定义切换供应商时需要保留的共享配置
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
- **代理面板交互优化**:将代理开关从折叠面板标题移入面板内部,紧邻应用接管选项。确保用户启用代理后能立即看到接管配置,避免「只开代理不接管」的常见误操作
|
||||
- **OpenCode/OpenClaw 手动导入**:移除启动时自动导入供应商配置的行为,改为在空状态页显示「导入当前配置」按钮,与 Claude/Codex/Gemini 保持一致
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
- **「跟随系统」主题不自动更新**:改用 Tauri 原生主题追踪(`set_window_theme(None)`),使 WebView 的 `prefers-color-scheme` 媒体查询能正确响应 OS 主题切换
|
||||
- **紧凑模式无法退出**:恢复 `toolbarRef` 上的 `flex-1` class,修复 `useAutoCompact` 的退出条件因宽度计算错误而永远不触发的问题
|
||||
- **代理接管 Toast 显示 {{app}}**:为 proxy takeover 的 i18next `t()` 调用补充缺失的 `app` 插值参数
|
||||
- **Windows 协议处理副作用**:在 Windows 上禁用环境检查和一键安装功能,防止协议处理程序注册引发的意外副作用
|
||||
|
||||
---
|
||||
|
||||
## 说明与注意事项
|
||||
|
||||
- **通用配置片段已恢复**:如果你在 v3.10.x 及更早版本中使用了此功能,它的工作方式与之前完全一致。用它来定义切换供应商时需要保留的共享配置。
|
||||
- **v3.11.0 部分键值合并用户**:如果你在 v3.11.0 中切换供应商后发现配置字段丢失,请重新导入配置以恢复。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [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.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.11.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.11.1-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.11.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现「未知开发者」警告,请先关闭,然后前往「系统设置」→「隐私与安全性」→ 点击「仍要打开」,之后便可以正常打开
|
||||
|
||||
### 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` |
|
||||
@@ -99,9 +99,9 @@
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 文档版本:v3.11.1
|
||||
- 最后更新:2026-02-28
|
||||
- 适用于 CC Switch v3.11.1+
|
||||
- 文档版本:v3.10.3
|
||||
- 最后更新:2026-02-09
|
||||
- 适用于 CC Switch v3.10.0+
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.11.1",
|
||||
"version": "3.10.3",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.11.0"
|
||||
version = "3.10.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.11.1"
|
||||
version = "3.10.3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -164,38 +164,6 @@ 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,
|
||||
@@ -263,26 +231,3 @@ pub async fn set_common_config_snippet(
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -123,24 +123,6 @@ 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> {
|
||||
@@ -168,9 +150,3 @@ 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())
|
||||
}
|
||||
|
||||
@@ -133,40 +133,27 @@ pub async fn get_tool_versions(
|
||||
tools: Option<Vec<String>>,
|
||||
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||
) -> Result<Vec<ToolVersion>, String> {
|
||||
// Windows: completely disable tool version detection to prevent
|
||||
// accidentally launching apps (e.g. Claude Code) via protocol handlers.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (tools, wsl_shell_by_tool);
|
||||
return Ok(Vec::new());
|
||||
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||
VALID_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| set.contains(t))
|
||||
.collect()
|
||||
} else {
|
||||
VALID_TOOLS.to_vec()
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
|
||||
for tool in requested {
|
||||
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||||
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||||
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||||
|
||||
results.push(get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||
VALID_TOOLS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| set.contains(t))
|
||||
.collect()
|
||||
} else {
|
||||
VALID_TOOLS.to_vec()
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
|
||||
for tool in requested {
|
||||
let pref = wsl_shell_by_tool.as_ref().and_then(|m| m.get(tool));
|
||||
let tool_wsl_shell = pref.and_then(|p| p.wsl_shell.as_deref());
|
||||
let tool_wsl_shell_flag = pref.and_then(|p| p.wsl_shell_flag.as_deref());
|
||||
|
||||
results.push(
|
||||
get_single_tool_version_impl(tool, tool_wsl_shell, tool_wsl_shell_flag).await,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取单个工具的版本信息(内部实现)
|
||||
@@ -670,7 +657,6 @@ 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(),
|
||||
@@ -708,6 +694,12 @@ 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
|
||||
}
|
||||
|
||||
/// 打开指定提供商的终端
|
||||
///
|
||||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||
|
||||
@@ -36,6 +36,19 @@ 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]
|
||||
@@ -71,3 +84,16 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
@@ -149,141 +147,6 @@ 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> {
|
||||
@@ -338,25 +201,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::{lock_conn, Database};
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
use chrono::{Local, Utc};
|
||||
use chrono::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_{}", Local::now().format("%Y%m%d_%H%M%S"));
|
||||
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
let mut backup_id = base_id.clone();
|
||||
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||
let mut counter = 1;
|
||||
@@ -531,29 +531,4 @@ 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod omo;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod proxy;
|
||||
@@ -15,3 +16,4 @@ pub mod universal_providers;
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
pub use failover::FailoverQueueItem;
|
||||
pub use omo::OmoGlobalConfig;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OmoGlobalConfig {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub schema_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sisyphus_agent: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub disabled_agents: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub disabled_mcps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub disabled_hooks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub disabled_skills: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lsp: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experimental: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background_task: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub browser_automation_engine: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub claude_code: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub other_fields: Option<serde_json::Value>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Default for OmoGlobalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "global".to_string(),
|
||||
schema_url: None,
|
||||
sisyphus_agent: None,
|
||||
disabled_agents: vec![],
|
||||
disabled_mcps: vec![],
|
||||
disabled_hooks: vec![],
|
||||
disabled_skills: vec![],
|
||||
lsp: None,
|
||||
experimental: None,
|
||||
background_task: None,
|
||||
browser_automation_engine: None,
|
||||
claude_code: None,
|
||||
other_fields: None,
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn get_omo_global_config(&self, key: &str) -> Result<OmoGlobalConfig, AppError> {
|
||||
let json_str = self.get_setting(key)?;
|
||||
match json_str {
|
||||
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))),
|
||||
None => Ok(OmoGlobalConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_omo_global_config(
|
||||
&self,
|
||||
key: &str,
|
||||
config: &OmoGlobalConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json_str = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?;
|
||||
self.set_setting(key, &json_str)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -375,19 +375,6 @@ 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",
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 通用配置片段 (Common Config Snippet) ---
|
||||
// --- Config Snippets 辅助方法 ---
|
||||
|
||||
/// 获取通用配置片段
|
||||
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||
|
||||
@@ -58,9 +58,6 @@ impl Database {
|
||||
// 4. 迁移 Skills
|
||||
Self::migrate_skills(tx, config)?;
|
||||
|
||||
// 5. 迁移 Common Config
|
||||
Self::migrate_common_config(tx, config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -212,34 +209,4 @@ impl Database {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 迁移通用配置片段
|
||||
fn migrate_common_config(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_claude", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_codex", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["common_config_gemini", snippet],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub use dao::FailoverQueueItem;
|
||||
pub use dao::OmoGlobalConfig;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-8
@@ -448,7 +448,18 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||
}
|
||||
|
||||
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
// 2. OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 provider 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"),
|
||||
}
|
||||
|
||||
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
{
|
||||
let has_omo = app_state
|
||||
.db
|
||||
@@ -499,6 +510,17 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.4 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
||||
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
||||
// 需要遍历 models.providers 字段下的每个供应商并导入
|
||||
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
@@ -823,12 +845,10 @@ 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,
|
||||
@@ -893,11 +913,9 @@ 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,
|
||||
@@ -1019,9 +1037,11 @@ 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,
|
||||
@@ -1031,8 +1051,6 @@ 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤t_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, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_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(())
|
||||
}
|
||||
}
|
||||
|
||||
+213
-44
@@ -1,4 +1,5 @@
|
||||
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;
|
||||
@@ -12,11 +13,12 @@ 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>);
|
||||
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
|
||||
|
||||
// ── Variant descriptor ─────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +28,9 @@ 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,
|
||||
}
|
||||
@@ -37,7 +41,23 @@ 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",
|
||||
};
|
||||
@@ -48,7 +68,18 @@ 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",
|
||||
};
|
||||
@@ -104,6 +135,47 @@ 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>) {
|
||||
@@ -112,6 +184,15 @@ 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 {
|
||||
@@ -133,7 +214,9 @@ 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 {
|
||||
@@ -142,10 +225,15 @@ impl OmoService {
|
||||
None
|
||||
};
|
||||
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||
(agents, categories, other_fields)
|
||||
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)
|
||||
});
|
||||
|
||||
let merged = Self::build_config(v, profile_data.as_ref());
|
||||
let merged = Self::merge_config(v, &global, profile_data.as_ref());
|
||||
let config_path = Self::config_path(v);
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
@@ -153,20 +241,56 @@ 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 build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
||||
fn merge_config(
|
||||
v: &OmoVariant,
|
||||
global: &OmoGlobalConfig,
|
||||
profile_data: Option<&OmoProfileData>,
|
||||
) -> Value {
|
||||
let mut result = Map::new();
|
||||
if let Some((agents, categories, other_fields)) = profile_data {
|
||||
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -186,12 +310,18 @@ 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, &["agents", "categories"]);
|
||||
let other = Self::extract_other_fields_with_keys(&obj, v.known_keys);
|
||||
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!(
|
||||
"{} {}",
|
||||
@@ -254,17 +384,22 @@ impl OmoService {
|
||||
None
|
||||
};
|
||||
|
||||
let other = Self::extract_other_fields_with_keys(obj, &["agents", "categories"]);
|
||||
let other = Self::extract_other_fields_with_keys(obj, v.known_keys);
|
||||
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,
|
||||
}
|
||||
@@ -347,24 +482,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_config_empty() {
|
||||
let merged = OmoService::build_config(&STANDARD, None);
|
||||
fn test_merge_config_empty() {
|
||||
let global = OmoGlobalConfig::default();
|
||||
let merged = OmoService::merge_config(&STANDARD, &global, None);
|
||||
assert!(merged.is_object());
|
||||
assert!(merged.as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_config_with_profile() {
|
||||
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()
|
||||
};
|
||||
let agents = Some(serde_json::json!({
|
||||
"sisyphus": { "model": "claude-opus-4-5" }
|
||||
}));
|
||||
let categories = None;
|
||||
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 other_fields = None;
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
||||
@@ -374,7 +511,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() {
|
||||
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() {
|
||||
let obj = serde_json::json!({
|
||||
"$schema": "https://example.com/schema.json",
|
||||
"disabled_agents": ["oracle"],
|
||||
@@ -397,53 +555,64 @@ 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!(
|
||||
other_obj.get("$schema").unwrap(),
|
||||
"https://example.com/schema.json"
|
||||
data.global.schema_url.as_deref(),
|
||||
Some("https://example.com/schema.json")
|
||||
);
|
||||
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
|
||||
|
||||
assert_eq!(
|
||||
other_obj.get("disabled_agents").unwrap(),
|
||||
&serde_json::json!(["oracle"])
|
||||
data.other_fields,
|
||||
Some(serde_json::json!({
|
||||
"custom_top_level": { "enabled": true }
|
||||
}))
|
||||
);
|
||||
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"));
|
||||
assert_eq!(data.global.other_fields, data.other_fields);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_config_ignores_non_object_other_fields() {
|
||||
fn test_merge_config_ignores_non_object_other_fields() {
|
||||
let global = OmoGlobalConfig {
|
||||
other_fields: Some(serde_json::json!(["global_non_object"])),
|
||||
..Default::default()
|
||||
};
|
||||
let agents = None;
|
||||
let categories = None;
|
||||
let other_fields = Some(serde_json::json!("profile_non_object"));
|
||||
let profile_data = (agents, categories, other_fields);
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
|
||||
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
|
||||
let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert!(!obj.contains_key("0"));
|
||||
assert!(!obj.contains_key("global_non_object"));
|
||||
assert!(!obj.contains_key("profile_non_object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_config_slim_excludes_categories() {
|
||||
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()
|
||||
};
|
||||
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
|
||||
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
|
||||
let other_fields = Some(serde_json::json!({
|
||||
"$schema": "https://slim.schema",
|
||||
"disabled_agents": ["oracle"]
|
||||
}));
|
||||
let profile_data = (agents, categories, other_fields);
|
||||
let other_fields = None;
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
|
||||
let merged = OmoService::build_config(&SLIM, Some(&profile_data));
|
||||
let merged = OmoService::merge_config(&SLIM, &global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
// Slim should NOT include categories
|
||||
// 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"));
|
||||
assert!(!obj.contains_key("categories"));
|
||||
|
||||
// Slim SHOULD include these
|
||||
|
||||
@@ -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().map_or(false, |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(¤t_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
|
||||
|
||||
@@ -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,11 +133,10 @@ impl ProviderService {
|
||||
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||
if app_type.is_additive_mode() {
|
||||
// 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"))
|
||||
// OMO providers use exclusive mode and write to dedicated config file.
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
// Do not auto-enable newly added OMO / OMO Slim providers.
|
||||
// Do not auto-enable newly added OMO providers.
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -192,7 +151,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)
|
||||
@@ -273,7 +232,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)?;
|
||||
}
|
||||
@@ -300,6 +259,18 @@ 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 {
|
||||
@@ -315,6 +286,18 @@ 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 {
|
||||
@@ -529,8 +512,6 @@ 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());
|
||||
}
|
||||
|
||||
@@ -540,9 +521,6 @@ 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());
|
||||
}
|
||||
|
||||
@@ -560,7 +538,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(¤t_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(), ¤t_provider)
|
||||
{
|
||||
@@ -584,11 +564,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)
|
||||
}
|
||||
@@ -598,222 +575,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(¤t_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.
|
||||
@@ -826,6 +587,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,
|
||||
@@ -921,10 +687,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 => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -37,27 +37,12 @@ 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(r1);
|
||||
sessions.extend(r2);
|
||||
sessions.extend(r3);
|
||||
sessions.extend(r4);
|
||||
sessions.extend(r5);
|
||||
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.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
|
||||
@@ -7,9 +7,7 @@ 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, read_head_tail_lines, truncate_summary,
|
||||
};
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "claude";
|
||||
|
||||
@@ -75,61 +73,60 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
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;
|
||||
|
||||
// Extract metadata from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
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 created_at.is_none() {
|
||||
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for line in tail.iter().rev() {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
let message = match value.get("message") {
|
||||
Some(message) => message,
|
||||
None => 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;
|
||||
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
@@ -9,17 +8,10 @@ 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, read_head_tail_lines, truncate_summary,
|
||||
};
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, 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();
|
||||
@@ -82,21 +74,32 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
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;
|
||||
|
||||
// Extract metadata from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if created_at.is_none() {
|
||||
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
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 value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if session_id.is_none() {
|
||||
@@ -115,34 +118,27 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
created_at.get_or_insert(ts);
|
||||
}
|
||||
}
|
||||
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;
|
||||
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for line in tail.iter().rev() {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
let payload = match value.get("payload") {
|
||||
Some(payload) => payload,
|
||||
None => continue,
|
||||
};
|
||||
if last_active_at.is_none() {
|
||||
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||
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;
|
||||
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
@@ -170,7 +166,10 @@ 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();
|
||||
UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||
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())
|
||||
}
|
||||
|
||||
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
|
||||
@@ -7,9 +7,7 @@ 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, read_head_tail_lines, truncate_summary,
|
||||
};
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "openclaw";
|
||||
|
||||
@@ -116,22 +114,30 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
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;
|
||||
|
||||
// Extract metadata and first message summary from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if created_at.is_none() {
|
||||
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
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);
|
||||
}
|
||||
|
||||
let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
@@ -155,28 +161,25 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
if event_type != "message" {
|
||||
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,
|
||||
};
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
last_active_at = Some(ts);
|
||||
break;
|
||||
// Extract first message content for summary
|
||||
if summary.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(msg) => msg,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
// Fall back to filename as session ID
|
||||
|
||||
@@ -136,7 +136,6 @@ 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()
|
||||
@@ -148,12 +147,8 @@ 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();
|
||||
|
||||
// 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)
|
||||
};
|
||||
// Get summary from first user message
|
||||
let summary = get_first_user_summary(storage, &session_id);
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
|
||||
@@ -1,50 +1,6 @@
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.11.1",
|
||||
"version": "3.10.3",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+235
-244
@@ -964,261 +964,252 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-w-0 items-center justify-end gap-1.5">
|
||||
{currentView === "providers" &&
|
||||
activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
settingsData?.enableLocalProxy && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isCurrentAppTakeoverActive
|
||||
? "opacity-100 max-w-[100px] scale-100"
|
||||
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="flex flex-1 min-w-0 overflow-x-hidden justify-end items-center"
|
||||
>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="flex flex-1 min-w-0 overflow-x-hidden justify-end items-center"
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
{currentView === "prompts" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("mcp.importExisting")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
{t("mcp.addMcp")}
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("mcp.importExisting")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("mcp.addMcp")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<FolderArchive className="w-4 h-4 mr-2" />
|
||||
{t("skills.installFromZip.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openImport()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("skills.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
{t("skills.discover")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={isToolbarCompact}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={
|
||||
activeApp === "openclaw" ? "openclaw" : "default"
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{activeApp === "openclaw" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("workspace")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("workspace.manage")}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawEnv")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.env.title")}
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawTools")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.tools.title")}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawAgents")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.agents.title")}
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skills")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSkillsSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSessionSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||
}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<FolderArchive className="w-4 h-4 mr-2" />
|
||||
{t("skills.installFromZip.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("skills.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skillsDiscovery")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
{t("skills.discover")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "skillsDiscovery" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentView === "providers" && (
|
||||
<>
|
||||
{activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
settingsData?.enableLocalProxy && (
|
||||
<>
|
||||
<ProxyToggle activeApp={activeApp} />
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isCurrentAppTakeoverActive
|
||||
? "opacity-100 max-w-[100px] scale-100"
|
||||
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
>
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={isToolbarCompact}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeApp === "openclaw" ? "openclaw" : "default"}
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{activeApp === "openclaw" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("workspace")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("workspace.manage")}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawEnv")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.env.title")}
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawTools")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.tools.title")}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("openclawAgents")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("openclaw.agents.title")}
|
||||
>
|
||||
<Cpu className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("skills")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSkillsSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSessionSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,12 +53,15 @@ 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);
|
||||
|
||||
@@ -24,6 +24,7 @@ interface ProviderActionsProps {
|
||||
isTesting?: boolean;
|
||||
isProxyTakeover?: boolean;
|
||||
isOmo?: boolean;
|
||||
isLastOmo?: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
@@ -48,6 +49,7 @@ export function ProviderActions({
|
||||
isTesting: _isTesting, // Hidden: stream check feature disabled
|
||||
isProxyTakeover = false,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
@@ -190,7 +192,11 @@ export function ProviderActions({
|
||||
|
||||
const buttonState = getMainButtonState();
|
||||
|
||||
const canDelete = isOmo || isAdditiveMode ? true : !isCurrent;
|
||||
const canDelete = isOmo
|
||||
? !(isLastOmo && isCurrent)
|
||||
: isAdditiveMode
|
||||
? true
|
||||
: !isCurrent;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -28,7 +28,9 @@ 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;
|
||||
@@ -92,7 +94,9 @@ export function ProviderCard({
|
||||
appId,
|
||||
isInConfig = true,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
isOmoSlim = false,
|
||||
isLastOmoSlim = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -121,6 +125,7 @@ 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);
|
||||
@@ -385,6 +390,7 @@ export function ProviderCard({
|
||||
isTesting={isTesting}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
isOmo={isAnyOmo}
|
||||
isLastOmo={isLastAnyOmo}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
} from "@/lib/query/failover";
|
||||
import {
|
||||
useCurrentOmoProviderId,
|
||||
useOmoProviderCount,
|
||||
useCurrentOmoSlimProviderId,
|
||||
useOmoSlimProviderCount,
|
||||
} from "@/lib/query/omo";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -140,7 +142,9 @@ 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 => {
|
||||
@@ -179,17 +183,7 @@ export function ProviderList({
|
||||
// Import current live config as default provider
|
||||
const queryClient = useQueryClient();
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (): Promise<boolean> => {
|
||||
if (appId === "opencode") {
|
||||
const count = await providersApi.importOpenCodeFromLive();
|
||||
return count > 0;
|
||||
}
|
||||
if (appId === "openclaw") {
|
||||
const count = await providersApi.importOpenClawFromLive();
|
||||
return count > 0;
|
||||
}
|
||||
return providersApi.importDefault(appId);
|
||||
},
|
||||
mutationFn: () => providersApi.importDefault(appId),
|
||||
onSuccess: (imported) => {
|
||||
if (imported) {
|
||||
queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
||||
@@ -255,11 +249,15 @@ export function ProviderList({
|
||||
);
|
||||
}
|
||||
|
||||
// Only show import button for standard apps (not additive-mode apps like OpenCode/OpenClaw)
|
||||
const showImportButton =
|
||||
appId === "claude" || appId === "codex" || appId === "gemini";
|
||||
|
||||
if (sortedProviders.length === 0) {
|
||||
return (
|
||||
<ProviderEmptyState
|
||||
onCreate={onCreate}
|
||||
onImport={() => importMutation.mutate()}
|
||||
onImport={showImportButton ? () => importMutation.mutate() : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -295,7 +293,15 @@ 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}
|
||||
@@ -414,7 +420,9 @@ 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;
|
||||
@@ -445,7 +453,9 @@ function SortableProviderCard({
|
||||
appId,
|
||||
isInConfig,
|
||||
isOmo,
|
||||
isLastOmo,
|
||||
isOmoSlim,
|
||||
isLastOmoSlim,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -490,7 +500,9 @@ 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 && category !== "cloud_provider" && (
|
||||
{/* API 格式选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
<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}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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,23 +722,14 @@ 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) {
|
||||
unmatchedCount++;
|
||||
} else if (updatedAgents[agentDef.key]?.model) {
|
||||
alreadySetCount++;
|
||||
} else {
|
||||
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
|
||||
updatedAgents[agentDef.key] = {
|
||||
...updatedAgents[agentDef.key],
|
||||
model: recommendedValue,
|
||||
};
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
onAgentsChange(updatedAgents);
|
||||
@@ -747,50 +738,15 @@ export function OmoFormFields({
|
||||
const updatedCategories = { ...categories };
|
||||
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
||||
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
||||
if (!recommendedValue) {
|
||||
unmatchedCount++;
|
||||
} else if (updatedCategories[catDef.key]?.model) {
|
||||
alreadySetCount++;
|
||||
} else {
|
||||
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ProviderTestConfig,
|
||||
ProviderProxyConfig,
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
providerPresets,
|
||||
@@ -39,16 +40,13 @@ import {
|
||||
import { OpenCodeFormFields } from "./OpenCodeFormFields";
|
||||
import { OpenClawFormFields } from "./OpenClawFormFields";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
import {
|
||||
applyTemplateValues,
|
||||
hasApiKeyField,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { applyTemplateValues } 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";
|
||||
@@ -56,6 +54,7 @@ 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,
|
||||
@@ -69,17 +68,15 @@ 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,
|
||||
@@ -187,6 +184,13 @@ 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");
|
||||
@@ -237,6 +241,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,
|
||||
@@ -247,6 +300,7 @@ export function ProviderForm({
|
||||
selectedPresetId,
|
||||
category,
|
||||
appType: appId,
|
||||
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
|
||||
});
|
||||
|
||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||
@@ -270,15 +324,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,
|
||||
@@ -376,37 +421,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,
|
||||
@@ -422,7 +436,6 @@ export function ProviderForm({
|
||||
handleGeminiConfigChange,
|
||||
resetGeminiConfig,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
} = useGeminiConfigState({
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
});
|
||||
@@ -473,23 +486,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 {
|
||||
@@ -515,6 +511,7 @@ export function ProviderForm({
|
||||
|
||||
const omoDraft = useOmoDraftState({
|
||||
initialOmoSettings,
|
||||
queriedOmoGlobalConfig,
|
||||
isEditMode,
|
||||
appId,
|
||||
category,
|
||||
@@ -528,8 +525,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();
|
||||
@@ -553,7 +548,7 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
||||
if (appId === "opencode" && category !== "omo") {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
if (!opencodeForm.opencodeProviderKey.trim()) {
|
||||
toast.error(t("opencode.providerKeyRequired"));
|
||||
@@ -599,8 +594,7 @@ export function ProviderForm({
|
||||
}
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
|
||||
if (category !== "official" && category !== "cloud_provider") {
|
||||
if (category !== "official") {
|
||||
if (appId === "claude") {
|
||||
if (!baseUrl.trim()) {
|
||||
toast.error(
|
||||
@@ -685,6 +679,7 @@ 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;
|
||||
}
|
||||
@@ -733,10 +728,9 @@ export function ProviderForm({
|
||||
};
|
||||
|
||||
if (appId === "opencode") {
|
||||
if (isAnyOmoCategory) {
|
||||
if (category === "omo") {
|
||||
if (!isEditMode) {
|
||||
const prefix = category === "omo" ? "omo" : "omo-slim";
|
||||
payload.providerKey = `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
payload.providerKey = `omo-${crypto.randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
} else {
|
||||
payload.providerKey = opencodeForm.opencodeProviderKey;
|
||||
@@ -745,8 +739,8 @@ export function ProviderForm({
|
||||
payload.providerKey = openclawForm.openclawProviderKey;
|
||||
}
|
||||
|
||||
if (isAnyOmoCategory && !payload.presetCategory) {
|
||||
payload.presetCategory = category;
|
||||
if (category === "omo" && !payload.presetCategory) {
|
||||
payload.presetCategory = "omo";
|
||||
}
|
||||
|
||||
if (activePreset) {
|
||||
@@ -823,6 +817,10 @@ export function ProviderForm({
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiFormat
|
||||
: undefined,
|
||||
apiKeyField:
|
||||
appId === "claude" && category !== "official"
|
||||
? localApiKeyField
|
||||
: undefined,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
@@ -845,8 +843,7 @@ export function ProviderForm({
|
||||
);
|
||||
}, [groupedPresets]);
|
||||
|
||||
const shouldShowSpeedTest =
|
||||
category !== "official" && category !== "cloud_provider";
|
||||
const shouldShowSpeedTest = category !== "official";
|
||||
|
||||
const {
|
||||
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
|
||||
@@ -998,10 +995,10 @@ export function ProviderForm({
|
||||
const preset = entry.preset as OpenCodeProviderPreset;
|
||||
const config = preset.settingsConfig;
|
||||
|
||||
if (preset.category === "omo" || preset.category === "omo-slim") {
|
||||
if (preset.category === "omo") {
|
||||
omoDraft.resetOmoDraftState();
|
||||
form.reset({
|
||||
name: preset.category === "omo" ? "OMO" : "OMO Slim",
|
||||
name: "OMO",
|
||||
websiteUrl: preset.websiteUrl ?? "",
|
||||
settingsConfig: JSON.stringify({}, null, 2),
|
||||
icon: preset.icon ?? "",
|
||||
@@ -1061,6 +1058,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 ?? "",
|
||||
@@ -1105,7 +1108,7 @@ export function ProviderForm({
|
||||
<BasicFormFields
|
||||
form={form}
|
||||
beforeNameSlot={
|
||||
appId === "opencode" && !isAnyOmoCategory ? (
|
||||
appId === "opencode" && category !== "omo" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="opencode-key">
|
||||
{t("opencode.providerKey")}
|
||||
@@ -1230,11 +1233,10 @@ export function ProviderForm({
|
||||
{appId === "claude" && (
|
||||
<ClaudeFormFields
|
||||
providerId={providerId}
|
||||
shouldShowApiKey={
|
||||
(category !== "cloud_provider" ||
|
||||
hasApiKeyField(form.getValues("settingsConfig"), "claude")) &&
|
||||
shouldShowApiKey(form.getValues("settingsConfig"), isEditMode)
|
||||
}
|
||||
shouldShowApiKey={shouldShowApiKey(
|
||||
form.getValues("settingsConfig"),
|
||||
isEditMode,
|
||||
)}
|
||||
apiKey={apiKey}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
category={category}
|
||||
@@ -1266,6 +1268,8 @@ export function ProviderForm({
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
apiFormat={localApiFormat}
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
apiKeyField={localApiKeyField}
|
||||
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1325,7 +1329,7 @@ export function ProviderForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{appId === "opencode" && !isAnyOmoCategory && (
|
||||
{appId === "opencode" && category !== "omo" && (
|
||||
<OpenCodeFormFields
|
||||
npm={opencodeForm.opencodeNpm}
|
||||
onNpmChange={opencodeForm.handleOpencodeNpmChange}
|
||||
@@ -1392,15 +1396,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}
|
||||
</>
|
||||
@@ -1411,32 +1408,27 @@ 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") ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("provider.configJson")}</Label>
|
||||
<JsonEditor
|
||||
value={omoDraft.mergedOmoJsonPreview}
|
||||
onChange={() => {}}
|
||||
rows={14}
|
||||
showValidation={false}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
<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"}
|
||||
/>
|
||||
) : appId === "opencode" &&
|
||||
category !== "omo" &&
|
||||
category !== "omo-slim" ? (
|
||||
@@ -1491,25 +1483,47 @@ 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>
|
||||
<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("claudeConfig.fullSettingsHint")}
|
||||
</p>
|
||||
</div>
|
||||
{settingsConfigErrorField}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isAnyOmoCategory && appId !== "opencode" && appId !== "openclaw" && (
|
||||
{category !== "omo" && appId !== "opencode" && appId !== "openclaw" && (
|
||||
<ProviderAdvancedConfig
|
||||
testConfig={testConfig}
|
||||
proxyConfig={proxyConfig}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
|
||||
import type { OmoGlobalConfig } from "@/types/omo";
|
||||
import type { PricingModelSourceOption } from "../ProviderAdvancedConfig";
|
||||
|
||||
// ── Default configs ──────────────────────────────────────────────────
|
||||
@@ -51,6 +52,15 @@ 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,312 +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: unknown) => {
|
||||
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: unknown) => {
|
||||
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,335 +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: unknown) => {
|
||||
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: unknown) => {
|
||||
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,469 +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: unknown) => {
|
||||
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: unknown) => {
|
||||
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,11 +1,22 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { OmoGlobalConfig } from "@/types/omo";
|
||||
import {
|
||||
mergeOmoConfigPreview,
|
||||
mergeOmoSlimConfigPreview,
|
||||
buildOmoSlimProfilePreview,
|
||||
buildOmoProfilePreview,
|
||||
} from "@/types/omo";
|
||||
import { type OmoGlobalConfigFieldsRef } from "../OmoGlobalConfigFields";
|
||||
import * as configApi from "@/lib/api/config";
|
||||
import {
|
||||
EMPTY_OMO_GLOBAL_CONFIG,
|
||||
buildOmoProfilePreview,
|
||||
} from "../helpers/opencodeFormUtils";
|
||||
|
||||
interface UseOmoDraftStateParams {
|
||||
initialOmoSettings: Record<string, unknown> | undefined;
|
||||
queriedOmoGlobalConfig: OmoGlobalConfig | undefined;
|
||||
isEditMode: boolean;
|
||||
appId: string;
|
||||
category?: string;
|
||||
@@ -22,15 +33,33 @@ 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;
|
||||
resetOmoDraftState: () => void;
|
||||
handleOmoGlobalConfigSave: () => Promise<void>;
|
||||
handleOmoEditClick: () => void;
|
||||
resetOmoDraftState: (useCommonConfig?: boolean) => void;
|
||||
setOmoGlobalState: React.Dispatch<
|
||||
React.SetStateAction<OmoGlobalConfig | null>
|
||||
>;
|
||||
}
|
||||
|
||||
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>>
|
||||
@@ -53,25 +82,118 @@ 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 (isSlim) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
return JSON.stringify(
|
||||
buildOmoSlimProfilePreview(omoAgents, omoOtherFieldsStr),
|
||||
buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
return JSON.stringify(
|
||||
buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [omoAgents, omoCategories, omoOtherFieldsStr, isSlim]);
|
||||
}, [
|
||||
useOmoCommonConfig,
|
||||
effectiveOmoGlobalConfig,
|
||||
omoAgents,
|
||||
omoCategories,
|
||||
omoOtherFieldsStr,
|
||||
isSlim,
|
||||
]);
|
||||
|
||||
const resetOmoDraftState = useCallback(() => {
|
||||
// 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) => {
|
||||
setOmoAgents({});
|
||||
setOmoCategories({});
|
||||
setOmoOtherFieldsStr("");
|
||||
setUseOmoCommonConfig(useCommonConfig);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -81,7 +203,18 @@ export function useOmoDraftState({
|
||||
setOmoCategories,
|
||||
omoOtherFieldsStr,
|
||||
setOmoOtherFieldsStr,
|
||||
useOmoCommonConfig,
|
||||
setUseOmoCommonConfig,
|
||||
isOmoConfigModalOpen,
|
||||
setIsOmoConfigModalOpen,
|
||||
isOmoSaving,
|
||||
omoGlobalConfigRef,
|
||||
omoFieldsKey,
|
||||
effectiveOmoGlobalConfig,
|
||||
mergedOmoJsonPreview,
|
||||
handleOmoGlobalConfigSave,
|
||||
handleOmoEditClick,
|
||||
resetOmoDraftState,
|
||||
setOmoGlobalState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,14 +7,11 @@ import {
|
||||
ListOrdered,
|
||||
Save,
|
||||
Loader2,
|
||||
Zap,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { toast } from "sonner";
|
||||
import { useFailoverQueue } from "@/lib/query/failover";
|
||||
@@ -28,21 +25,8 @@ import {
|
||||
} from "@/lib/query/proxy";
|
||||
import type { ProxyStatus } from "@/types/proxy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface ProxyPanelProps {
|
||||
enableLocalProxy: boolean;
|
||||
onEnableLocalProxyChange: (checked: boolean) => void;
|
||||
onToggleProxy: (checked: boolean) => Promise<void>;
|
||||
isProxyPending: boolean;
|
||||
}
|
||||
|
||||
export function ProxyPanel({
|
||||
enableLocalProxy,
|
||||
onEnableLocalProxyChange,
|
||||
onToggleProxy,
|
||||
isProxyPending,
|
||||
}: ProxyPanelProps) {
|
||||
export function ProxyPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { status, isRunning } = useProxyStatus();
|
||||
|
||||
@@ -199,98 +183,9 @@ export function ProxyPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
{/* [1] Enable proxy button on main page — always visible */}
|
||||
<ToggleRow
|
||||
icon={<Zap className="h-4 w-4 text-green-500" />}
|
||||
title={t("settings.advanced.proxy.enableFeature")}
|
||||
description={t("settings.advanced.proxy.enableFeatureDescription")}
|
||||
checked={enableLocalProxy}
|
||||
onCheckedChange={onEnableLocalProxyChange}
|
||||
/>
|
||||
|
||||
{/* [2] Proxy service toggle — always visible */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
|
||||
<Power className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{t("proxyConfig.proxyEnabled", {
|
||||
defaultValue: "代理服务",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
onCheckedChange={onToggleProxy}
|
||||
disabled={isProxyPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* [3] App takeover switches — animated, visible only when proxy is running */}
|
||||
<AnimatePresence>
|
||||
{isRunning && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-xl border-2 border-primary/20 bg-primary/5 p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-primary">
|
||||
{t("proxyConfig.appTakeover", {
|
||||
defaultValue: "应用接管",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||
const isEnabled =
|
||||
takeoverStatus?.[
|
||||
appType as keyof typeof takeoverStatus
|
||||
] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={appType}
|
||||
className="flex items-center justify-between rounded-md border border-primary/20 bg-background/60 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{appType}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTakeoverChange(appType, checked)
|
||||
}
|
||||
disabled={setTakeoverForApp.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.takeover.hint", {
|
||||
defaultValue:
|
||||
"选择要接管的应用,启用后该应用的请求将通过本地代理转发",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Running state: service info + stats */}
|
||||
<section className="space-y-6">
|
||||
{isRunning && status ? (
|
||||
<div className="space-y-6">
|
||||
{/* [4] Running info: address + current provider */}
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
@@ -368,7 +263,41 @@ export function ProxyPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* [5] Logging toggle */}
|
||||
{/* 应用接管开关 */}
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxyConfig.appTakeover", {
|
||||
defaultValue: "应用接管",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||
const isEnabled =
|
||||
takeoverStatus?.[
|
||||
appType as keyof typeof takeoverStatus
|
||||
] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={appType}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{appType}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTakeoverChange(appType, checked)
|
||||
}
|
||||
disabled={setTakeoverForApp.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志记录开关 */}
|
||||
<div className="pt-3 border-t border-border">
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
@@ -391,7 +320,7 @@ export function ProxyPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* [6] Provider queues */}
|
||||
{/* 供应商队列 - 按应用类型分组展示 */}
|
||||
{(claudeQueue.length > 0 ||
|
||||
codexQueue.length > 0 ||
|
||||
geminiQueue.length > 0) && (
|
||||
@@ -403,6 +332,7 @@ export function ProxyPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Claude 队列 */}
|
||||
{claudeQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="claude"
|
||||
@@ -415,6 +345,7 @@ export function ProxyPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex 队列 */}
|
||||
{codexQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="codex"
|
||||
@@ -427,6 +358,7 @@ export function ProxyPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gemini 队列 */}
|
||||
{geminiQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="gemini"
|
||||
@@ -442,7 +374,6 @@ export function ProxyPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* [7] Stats cards */}
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
@@ -477,7 +408,7 @@ export function ProxyPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* [8] Basic settings — address/port (only when stopped) */}
|
||||
{/* 基础设置 - 监听地址/端口 */}
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
@@ -565,7 +496,7 @@ export function ProxyPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stopped hint */}
|
||||
{/* 代理服务已停止提示 */}
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Server className="h-8 w-8" />
|
||||
@@ -577,7 +508,7 @@ export function ProxyPanel({
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxy.panel.stoppedDescription", {
|
||||
defaultValue: "使用上方开关即可启动服务",
|
||||
defaultValue: "使用右上角开关即可启动服务",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { relaunchApp } from "@/lib/updater";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { motion } from "framer-motion";
|
||||
import appIcon from "@/assets/icons/app-icon.png";
|
||||
import { isWindows } from "@/lib/platform";
|
||||
|
||||
interface AboutSectionProps {
|
||||
isPortable: boolean;
|
||||
@@ -200,7 +199,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
try {
|
||||
const [appVersion] = await Promise.all([
|
||||
getVersion(),
|
||||
...(isWindows() ? [] : [loadAllToolVersions()]),
|
||||
loadAllToolVersions(),
|
||||
]);
|
||||
|
||||
if (active) {
|
||||
@@ -424,173 +423,166 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{!isWindows() && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.localEnvCheck")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => loadAllToolVersions()}
|
||||
disabled={isLoadingTools}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
|
||||
}
|
||||
/>
|
||||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
||||
{TOOL_NAMES.map((toolName, index) => {
|
||||
const tool = toolVersions.find((item) => item.name === toolName);
|
||||
// Special case for OpenCode (capital C), others use capitalize
|
||||
const displayName =
|
||||
toolName === "opencode"
|
||||
? "OpenCode"
|
||||
: toolName.charAt(0).toUpperCase() + toolName.slice(1);
|
||||
const title = tool?.version || tool?.error || t("common.unknown");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={toolName}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{displayName}</span>
|
||||
{/* Environment Badge */}
|
||||
{tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (
|
||||
<span
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${ENV_BADGE_CONFIG[tool.env_type].className}`}
|
||||
>
|
||||
{t(ENV_BADGE_CONFIG[tool.env_type].labelKey)}
|
||||
</span>
|
||||
)}
|
||||
{/* WSL Shell Selector */}
|
||||
{tool?.env_type === "wsl" && (
|
||||
<Select
|
||||
value={wslShellByTool[toolName]?.wslShell || "auto"}
|
||||
onValueChange={(v) =>
|
||||
handleToolShellChange(toolName, v)
|
||||
}
|
||||
disabled={isLoadingTools || loadingTools[toolName]}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("common.auto")}
|
||||
</SelectItem>
|
||||
{WSL_SHELL_OPTIONS.map((shell) => (
|
||||
<SelectItem key={shell} value={shell}>
|
||||
{shell}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* WSL Shell Flag Selector */}
|
||||
{tool?.env_type === "wsl" && (
|
||||
<Select
|
||||
value={wslShellByTool[toolName]?.wslShellFlag || "auto"}
|
||||
onValueChange={(v) =>
|
||||
handleToolShellFlagChange(toolName, v)
|
||||
}
|
||||
disabled={isLoadingTools || loadingTools[toolName]}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("common.auto")}
|
||||
</SelectItem>
|
||||
{WSL_SHELL_FLAG_OPTIONS.map((flag) => (
|
||||
<SelectItem key={flag} value={flag}>
|
||||
{flag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-mono text-muted-foreground truncate"
|
||||
title={title}
|
||||
>
|
||||
{isLoadingTools
|
||||
? t("common.loading")
|
||||
: tool?.version
|
||||
? tool.version
|
||||
: tool?.error || t("common.notInstalled")}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h3 className="text-sm font-medium px-1">
|
||||
{t("settings.oneClickInstall")}
|
||||
</h3>
|
||||
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.oneClickInstallHint")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCopyInstallCommands}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => loadAllToolVersions()}
|
||||
disabled={isLoadingTools}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
|
||||
}
|
||||
/>
|
||||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
||||
{TOOL_NAMES.map((toolName, index) => {
|
||||
const tool = toolVersions.find((item) => item.name === toolName);
|
||||
// Special case for OpenCode (capital C), others use capitalize
|
||||
const displayName =
|
||||
toolName === "opencode"
|
||||
? "OpenCode"
|
||||
: toolName.charAt(0).toUpperCase() + toolName.slice(1);
|
||||
const title = tool?.version || tool?.error || t("common.unknown");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={toolName}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{displayName}</span>
|
||||
{/* Environment Badge */}
|
||||
{tool?.env_type && ENV_BADGE_CONFIG[tool.env_type] && (
|
||||
<span
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${ENV_BADGE_CONFIG[tool.env_type].className}`}
|
||||
>
|
||||
{t(ENV_BADGE_CONFIG[tool.env_type].labelKey)}
|
||||
</span>
|
||||
)}
|
||||
{/* WSL Shell Selector */}
|
||||
{tool?.env_type === "wsl" && (
|
||||
<Select
|
||||
value={wslShellByTool[toolName]?.wslShell || "auto"}
|
||||
onValueChange={(v) =>
|
||||
handleToolShellChange(toolName, v)
|
||||
}
|
||||
disabled={isLoadingTools || loadingTools[toolName]}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("common.auto")}
|
||||
</SelectItem>
|
||||
{WSL_SHELL_OPTIONS.map((shell) => (
|
||||
<SelectItem key={shell} value={shell}>
|
||||
{shell}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* WSL Shell Flag Selector */}
|
||||
{tool?.env_type === "wsl" && (
|
||||
<Select
|
||||
value={
|
||||
wslShellByTool[toolName]?.wslShellFlag || "auto"
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
handleToolShellFlagChange(toolName, v)
|
||||
}
|
||||
disabled={isLoadingTools || loadingTools[toolName]}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{t("common.auto")}
|
||||
</SelectItem>
|
||||
{WSL_SHELL_FLAG_OPTIONS.map((flag) => (
|
||||
<SelectItem key={flag} value={flag}>
|
||||
{flag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{isLoadingTools || loadingTools[toolName] ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : tool?.version ? (
|
||||
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" />
|
||||
)
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-mono text-muted-foreground truncate"
|
||||
title={title}
|
||||
>
|
||||
{isLoadingTools
|
||||
? t("common.loading")
|
||||
: tool?.version
|
||||
? tool.version
|
||||
: tool?.error || t("common.notInstalled")}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
|
||||
{ONE_CLICK_INSTALL_COMMANDS}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isWindows() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h3 className="text-sm font-medium px-1">
|
||||
{t("settings.oneClickInstall")}
|
||||
</h3>
|
||||
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.oneClickInstallHint")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCopyInstallCommands}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
|
||||
{ONE_CLICK_INSTALL_COMMANDS}
|
||||
</pre>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, RotateCcw, Check, X, Download, Trash2 } from "lucide-react";
|
||||
import { Pencil, RotateCcw, Check, X } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -67,20 +67,9 @@ export function BackupListSection({
|
||||
onSettingsChange,
|
||||
}: BackupListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
backups,
|
||||
isLoading,
|
||||
create,
|
||||
isCreating,
|
||||
restore,
|
||||
isRestoring,
|
||||
rename,
|
||||
isRenaming,
|
||||
remove,
|
||||
isDeleting,
|
||||
} = useBackupManager();
|
||||
const { backups, isLoading, restore, isRestoring, rename, isRenaming } =
|
||||
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("");
|
||||
|
||||
@@ -121,26 +110,6 @@ 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 {
|
||||
@@ -252,45 +221,11 @@ export function BackupListSection({
|
||||
|
||||
{/* Backup list */}
|
||||
<div>
|
||||
<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>
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
{t("settings.backupManager.title", {
|
||||
defaultValue: "Database Backups",
|
||||
})}
|
||||
</h4>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground py-2">Loading...</div>
|
||||
@@ -363,30 +298,18 @@ export function BackupListSection({
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleStartRename(backup.filename)}
|
||||
disabled={isRestoring || isRenaming || isDeleting}
|
||||
disabled={isRestoring || isRenaming}
|
||||
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 || isDeleting}
|
||||
disabled={isRestoring}
|
||||
onClick={() => setConfirmFilename(backup.filename)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
@@ -406,7 +329,7 @@ export function BackupListSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore Confirmation Dialog */}
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={!!confirmFilename}
|
||||
onOpenChange={(open) => !open && setConfirmFilename(null)}
|
||||
@@ -445,50 +368,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Server, Activity, Zap, Globe } from "lucide-react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { Server, Activity, ChevronDown, Zap, Globe } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||
@@ -73,22 +76,28 @@ export function ProxyTabContent({
|
||||
{/* Local Proxy */}
|
||||
<AccordionItem
|
||||
value="proxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-green-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.proxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.proxy.description")}
|
||||
</p>
|
||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-green-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.proxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.proxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
|
||||
<div className="flex items-center gap-4 pl-4">
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="gap-1.5 h-6 ml-auto mr-2"
|
||||
className="gap-1.5 h-6"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
@@ -97,17 +106,28 @@ export function ProxyTabContent({
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
onCheckedChange={handleToggleProxy}
|
||||
disabled={isProxyPending}
|
||||
/>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ProxyPanel
|
||||
enableLocalProxy={settings?.enableLocalProxy ?? false}
|
||||
onEnableLocalProxyChange={(checked) =>
|
||||
<ToggleRow
|
||||
icon={<Zap className="h-4 w-4 text-green-500" />}
|
||||
title={t("settings.advanced.proxy.enableFeature")}
|
||||
description={t(
|
||||
"settings.advanced.proxy.enableFeatureDescription",
|
||||
)}
|
||||
checked={settings?.enableLocalProxy ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onAutoSave({ enableLocalProxy: checked })
|
||||
}
|
||||
onToggleProxy={handleToggleProxy}
|
||||
isProxyPending={isProxyPending}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ProxyPanel />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -28,25 +27,13 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
||||
onCheckedChange={(value) => onChange({ launchOnStartup: 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={<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 })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
|
||||
|
||||
@@ -113,17 +113,33 @@ export function ThemeProvider({
|
||||
}
|
||||
};
|
||||
|
||||
// When "system", pass "system" so Tauri uses None (follows OS theme natively).
|
||||
// This keeps the WebView's prefers-color-scheme in sync with the real OS theme,
|
||||
// allowing effect #3's media query listener to fire on system theme changes.
|
||||
// Determine current effective theme
|
||||
if (theme === "system") {
|
||||
updateNativeTheme("system");
|
||||
const isDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
updateNativeTheme(isDark ? "dark" : "light");
|
||||
} else {
|
||||
updateNativeTheme(theme);
|
||||
}
|
||||
|
||||
// Listen to system theme changes for native window when in "system" mode
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (theme === "system" && !isCancelled) {
|
||||
updateNativeTheme(mediaQuery.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
|
||||
if (theme === "system") {
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (theme === "system") {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
}
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Calendar, Trash2, Plus, Search, X, FolderOpen } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Calendar, Trash2, Plus } from "lucide-react";
|
||||
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,
|
||||
type DailyMemorySearchResult,
|
||||
} from "@/lib/api/workspace";
|
||||
import { workspaceApi, type DailyMemoryFileInfo } from "@/lib/api/workspace";
|
||||
|
||||
interface DailyMemoryPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -58,16 +46,6 @@ 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);
|
||||
|
||||
@@ -83,100 +61,6 @@ 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);
|
||||
@@ -258,44 +142,24 @@ 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,
|
||||
isSearchOpen,
|
||||
searchTerm,
|
||||
executeSearch,
|
||||
]);
|
||||
}, [deletingFile, editingFile, loadFiles, t]);
|
||||
|
||||
// Back from edit mode to list mode — preserve search state
|
||||
// Back from edit mode to list mode
|
||||
const handleBackToList = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setContent("");
|
||||
void loadFiles();
|
||||
// Re-trigger search if active (file content may have changed)
|
||||
if (isSearchOpen && searchTerm.trim()) {
|
||||
void executeSearch(searchTerm);
|
||||
}
|
||||
}, [loadFiles, isSearchOpen, searchTerm, executeSearch]);
|
||||
}, [loadFiles]);
|
||||
|
||||
// Close panel entirely — clear search state
|
||||
// Close panel entirely
|
||||
const handleClose = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setContent("");
|
||||
setIsSearchOpen(false);
|
||||
setSearchTerm("");
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -350,142 +214,24 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 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")}
|
||||
>
|
||||
{/* Header with path and create button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
~/.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>
|
||||
<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>
|
||||
|
||||
{/* 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 ? (
|
||||
{/* File list */}
|
||||
{loadingList ? (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Circle,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
@@ -84,13 +83,8 @@ const WorkspaceFilesPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<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")}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
~/.openclaw/workspace/
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
|
||||
@@ -92,10 +92,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-5",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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-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",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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-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",
|
||||
ANTHROPIC_MODEL: "Ling-1T",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Ling-1T",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Ling-1T",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-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.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",
|
||||
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",
|
||||
},
|
||||
},
|
||||
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.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",
|
||||
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",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -478,61 +476,6 @@ 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",
|
||||
@@ -541,10 +484,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6",
|
||||
ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -588,71 +531,4 @@ 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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -227,59 +227,6 @@ 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",
|
||||
|
||||
@@ -139,70 +139,6 @@ 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",
|
||||
|
||||
@@ -112,14 +112,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "glm-5",
|
||||
name: "GLM-5",
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.001 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -136,8 +138,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "zhipu/glm-5" },
|
||||
modelCatalog: { "zhipu/glm-5": { alias: "GLM" } },
|
||||
model: { primary: "zhipu/glm-4.7" },
|
||||
modelCatalog: { "zhipu/glm-4.7": { alias: "GLM" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -150,14 +152,16 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "glm-5",
|
||||
name: "GLM-5",
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.001 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -174,8 +178,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "zhipu-en/glm-5" },
|
||||
modelCatalog: { "zhipu-en/glm-5": { alias: "GLM" } },
|
||||
model: { primary: "zhipu-en/glm-4.7" },
|
||||
modelCatalog: { "zhipu-en/glm-4.7": { alias: "GLM" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -188,8 +192,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "qwen3.5-plus",
|
||||
name: "Qwen3.5 Plus",
|
||||
id: "qwen3-max",
|
||||
name: "Qwen3 Max",
|
||||
contextWindow: 32000,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
},
|
||||
@@ -212,8 +216,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "qwen/qwen3.5-plus" },
|
||||
modelCatalog: { "qwen/qwen3.5-plus": { alias: "Qwen" } },
|
||||
model: { primary: "qwen/qwen3-max" },
|
||||
modelCatalog: { "qwen/qwen3-max": { alias: "Qwen" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -302,8 +306,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -326,8 +330,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.5" },
|
||||
modelCatalog: { "minimax/MiniMax-M2.5": { alias: "MiniMax" } },
|
||||
model: { primary: "minimax/MiniMax-M2.1" },
|
||||
modelCatalog: { "minimax/MiniMax-M2.1": { alias: "MiniMax" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -340,8 +344,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -364,8 +368,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "minimax-en/MiniMax-M2.5" },
|
||||
modelCatalog: { "minimax-en/MiniMax-M2.5": { alias: "MiniMax" } },
|
||||
model: { primary: "minimax-en/MiniMax-M2.1" },
|
||||
modelCatalog: { "minimax-en/MiniMax-M2.1": { alias: "MiniMax" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -461,7 +465,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
id: "doubao-seed-code-preview-latest",
|
||||
name: "DouBao Seed Code Preview",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
@@ -479,9 +483,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "doubaoseed/doubao-seed-2-0-code-preview-latest" },
|
||||
model: { primary: "doubaoseed/doubao-seed-code-preview-latest" },
|
||||
modelCatalog: {
|
||||
"doubaoseed/doubao-seed-2-0-code-preview-latest": { alias: "DouBao" },
|
||||
"doubaoseed/doubao-seed-code-preview-latest": { alias: "DouBao" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -494,8 +498,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "Ling-2.5-1T",
|
||||
name: "Ling 2.5 1T",
|
||||
id: "Ling-1T",
|
||||
name: "Ling 1T",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -510,8 +514,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "bailing/Ling-2.5-1T" },
|
||||
modelCatalog: { "bailing/Ling-2.5-1T": { alias: "BaiLing" } },
|
||||
model: { primary: "bailing/Ling-1T" },
|
||||
modelCatalog: { "bailing/Ling-1T": { alias: "BaiLing" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -558,8 +562,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -583,11 +587,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aihubmix/claude-sonnet-4-6",
|
||||
primary: "aihubmix/claude-sonnet-4-5-20250929",
|
||||
fallbacks: ["aihubmix/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aihubmix/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"aihubmix/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"aihubmix/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -602,8 +606,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -627,11 +631,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "dmxapi/claude-sonnet-4-6",
|
||||
primary: "dmxapi/claude-sonnet-4-5-20250929",
|
||||
fallbacks: ["dmxapi/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"dmxapi/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"dmxapi/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"dmxapi/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -646,8 +650,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -671,11 +675,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
primary: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"openrouter/anthropic/claude-sonnet-4.6": { alias: "Sonnet" },
|
||||
"openrouter/anthropic/claude-sonnet-4.5": { alias: "Sonnet" },
|
||||
"openrouter/anthropic/claude-opus-4.6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -690,8 +694,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "ZhipuAI/GLM-5",
|
||||
name: "GLM-5",
|
||||
id: "ZhipuAI/GLM-4.7",
|
||||
name: "GLM-4.7",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.001 },
|
||||
},
|
||||
@@ -714,8 +718,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "modelscope/ZhipuAI/GLM-5" },
|
||||
modelCatalog: { "modelscope/ZhipuAI/GLM-5": { alias: "GLM" } },
|
||||
model: { primary: "modelscope/ZhipuAI/GLM-4.7" },
|
||||
modelCatalog: { "modelscope/ZhipuAI/GLM-4.7": { alias: "GLM" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -728,8 +732,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -746,9 +750,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.5" },
|
||||
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.1" },
|
||||
modelCatalog: {
|
||||
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
|
||||
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -762,8 +766,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -780,9 +784,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.5" },
|
||||
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.1" },
|
||||
modelCatalog: {
|
||||
"siliconflow-en/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
|
||||
"siliconflow-en/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -830,8 +834,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -856,11 +860,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "packycode/claude-sonnet-4-6",
|
||||
primary: "packycode/claude-sonnet-4-5-20250929",
|
||||
fallbacks: ["packycode/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"packycode/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"packycode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"packycode/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -875,8 +879,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -902,11 +906,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "cubence/claude-sonnet-4-6",
|
||||
primary: "cubence/claude-sonnet-4-5-20250929",
|
||||
fallbacks: ["cubence/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"cubence/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"cubence/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"cubence/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -921,8 +925,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -948,11 +952,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aigocode/claude-sonnet-4-6",
|
||||
primary: "aigocode/claude-sonnet-4-5-20250929",
|
||||
fallbacks: ["aigocode/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aigocode/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"aigocode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"aigocode/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -967,8 +971,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -994,11 +998,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "rightcode/claude-sonnet-4-6",
|
||||
primary: "rightcode/claude-sonnet-4-5-20250929",
|
||||
fallbacks: ["rightcode/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"rightcode/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"rightcode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"rightcode/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -1013,8 +1017,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -1040,187 +1044,15 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aicodemirror/claude-sonnet-4-6",
|
||||
primary: "aicodemirror/claude-sonnet-4-5-20250929",
|
||||
fallbacks: ["aicodemirror/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aicodemirror/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"aicodemirror/claude-sonnet-4-5-20250929": { 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 ==========
|
||||
{
|
||||
|
||||
@@ -21,7 +21,6 @@ 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;
|
||||
|
||||
@@ -41,15 +40,15 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
> = {
|
||||
"@ai-sdk/openai-compatible": [
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
contextLimit: 204800,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "glm-5",
|
||||
name: "GLM 5",
|
||||
id: "glm-4.7",
|
||||
name: "GLM 4.7",
|
||||
contextLimit: 204800,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
@@ -316,50 +315,6 @@ 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",
|
||||
@@ -486,7 +441,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"glm-5": { name: "GLM-5" },
|
||||
"glm-4.7": { name: "GLM-4.7" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -518,7 +473,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"glm-5": { name: "GLM-5" },
|
||||
"glm-4.7": { name: "GLM-4.7" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -644,7 +599,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"ZhipuAI/GLM-5": { name: "GLM-5" },
|
||||
"ZhipuAI/GLM-4.7": { name: "GLM-4.7" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -748,7 +703,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"MiniMax-M2.5": { name: "MiniMax M2.5" },
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -780,7 +735,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"MiniMax-M2.5": { name: "MiniMax M2.5" },
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -812,9 +767,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"doubao-seed-2-0-code-preview-latest": {
|
||||
name: "Doubao Seed Code Preview",
|
||||
},
|
||||
"doubao-seed-code-preview-latest": { name: "Doubao Seed Code Preview" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -839,7 +792,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"Ling-2.5-1T": { name: "Ling 2.5-1T" },
|
||||
"Ling-1T": { name: "Ling 1T" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -890,7 +843,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -917,7 +870,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -944,7 +897,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" },
|
||||
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -998,7 +951,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -1026,7 +979,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -1055,7 +1008,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -1116,7 +1069,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4.6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -1133,141 +1086,7 @@ 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: "",
|
||||
|
||||
@@ -13,11 +13,6 @@ 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 () => {
|
||||
@@ -39,21 +34,12 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,11 +108,9 @@ export function useProxyStatus() {
|
||||
toast.success(
|
||||
variables.enabled
|
||||
? t("proxy.takeover.enabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `已接管 ${appLabel} 配置(请求将走本地代理)`,
|
||||
})
|
||||
: t("proxy.takeover.disabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `已恢复 ${appLabel} 配置`,
|
||||
}),
|
||||
{ closeButton: true },
|
||||
|
||||
+46
-97
@@ -51,15 +51,7 @@
|
||||
},
|
||||
"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}}",
|
||||
"hideAttribution": "Hide AI Attribution",
|
||||
"alwaysThinking": "Extended Thinking",
|
||||
"enableTeammates": "Teammates Mode"
|
||||
@@ -125,11 +117,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,17 +307,7 @@
|
||||
"rename": "Rename",
|
||||
"renameSuccess": "Backup renamed",
|
||||
"renameFailed": "Rename failed",
|
||||
"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."
|
||||
"namePlaceholder": "Enter new name"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV Cloud Sync",
|
||||
@@ -647,10 +625,7 @@
|
||||
"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!",
|
||||
"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!"
|
||||
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!"
|
||||
},
|
||||
"parameterConfig": "Parameter Config - {{name}} *",
|
||||
"mainModel": "Main Model (optional)",
|
||||
@@ -680,6 +655,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",
|
||||
@@ -751,33 +730,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}}"
|
||||
@@ -813,6 +774,38 @@
|
||||
"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",
|
||||
@@ -1200,60 +1193,22 @@
|
||||
"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": "Add Memory",
|
||||
"createToday": "Today's Note",
|
||||
"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.",
|
||||
"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)"
|
||||
"confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone."
|
||||
}
|
||||
},
|
||||
"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.)",
|
||||
@@ -1582,7 +1537,7 @@
|
||||
"currentProvider": "Current Provider:",
|
||||
"waitingFirstRequest": "Current Provider: Waiting for first request...",
|
||||
"stoppedTitle": "Proxy Service Stopped",
|
||||
"stoppedDescription": "Use the toggle above to start the service",
|
||||
"stoppedDescription": "Use the toggle in the top right to start the service",
|
||||
"openSettings": "Configure Proxy Service",
|
||||
"stats": {
|
||||
"activeConnections": "Active Connections",
|
||||
@@ -1673,12 +1628,6 @@
|
||||
"restartRequired": "Restart proxy service for address or port changes to take effect"
|
||||
},
|
||||
"switchFailed": "Switch failed: {{error}}",
|
||||
"takeover": {
|
||||
"hint": "Select apps to take over — once enabled, requests from that app will be routed through the local proxy",
|
||||
"enabled": "{{app}} takeover enabled",
|
||||
"disabled": "{{app}} takeover disabled",
|
||||
"failed": "Failed to toggle takeover"
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "Proxy service must be started to configure failover",
|
||||
"autoSwitch": "Auto Failover",
|
||||
@@ -1846,6 +1795,10 @@
|
||||
"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)",
|
||||
@@ -1875,10 +1828,6 @@
|
||||
"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:",
|
||||
|
||||
+48
-118
@@ -51,15 +51,7 @@
|
||||
},
|
||||
"claudeConfig": {
|
||||
"configLabel": "Claude Code settings.json (JSON) *",
|
||||
"writeCommonConfig": "共通設定を書き込む",
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
||||
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
|
||||
"fullSettingsHint": "Claude Code の settings.json 全文",
|
||||
"extractFromCurrent": "編集内容から抽出",
|
||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"hideAttribution": "AI署名を非表示",
|
||||
"alwaysThinking": "拡張思考",
|
||||
"enableTeammates": "Teammates モード"
|
||||
@@ -125,11 +117,7 @@
|
||||
"notes": "メモ",
|
||||
"notesPlaceholder": "例: 会社用アカウント",
|
||||
"configJson": "Config JSON",
|
||||
"writeCommonConfig": "共通設定を書き込む",
|
||||
"editCommonConfigButton": "共通設定を編集",
|
||||
"configJsonHint": "Claude Code の設定をすべて入力してください",
|
||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
||||
"editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます",
|
||||
"addProvider": "プロバイダーを追加",
|
||||
"sortUpdated": "並び順を更新しました",
|
||||
"usageSaved": "利用状況の設定を保存しました",
|
||||
@@ -319,17 +307,7 @@
|
||||
"rename": "名前変更",
|
||||
"renameSuccess": "バックアップの名前を変更しました",
|
||||
"renameFailed": "名前変更に失敗しました",
|
||||
"namePlaceholder": "新しい名前を入力",
|
||||
"createBackup": "今すぐバックアップ",
|
||||
"creating": "バックアップ中...",
|
||||
"createSuccess": "バックアップが作成されました",
|
||||
"createFailed": "バックアップに失敗しました",
|
||||
"delete": "削除",
|
||||
"deleting": "削除中...",
|
||||
"deleteSuccess": "バックアップを削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"deleteConfirmTitle": "バックアップの削除を確認",
|
||||
"deleteConfirmMessage": "このバックアップは完全に削除されます。この操作は取り消せません。"
|
||||
"namePlaceholder": "新しい名前を入力"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV クラウド同期",
|
||||
@@ -647,10 +625,7 @@
|
||||
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
||||
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
|
||||
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!",
|
||||
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!",
|
||||
"aicoding": "AI Coding は CC Switch ユーザー向けに特別割引を提供しています。初回チャージが 10% オフ!",
|
||||
"crazyrouter": "CrazyRouter は CC Switch ユーザー向けに特別ボーナスを提供しています。初回チャージで 30% の追加クレジットがもらえます!",
|
||||
"sssaicode": "SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます!"
|
||||
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!"
|
||||
},
|
||||
"parameterConfig": "パラメーター設定 - {{name}} *",
|
||||
"mainModel": "メインモデル(任意)",
|
||||
@@ -680,6 +655,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 モデル",
|
||||
@@ -751,33 +730,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}}"
|
||||
@@ -813,6 +774,38 @@
|
||||
"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": "カスタム設定",
|
||||
@@ -1200,60 +1193,22 @@
|
||||
"saveSuccess": "保存しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"openDirectory": "ファイルマネージャーで開く",
|
||||
"dailyMemory": {
|
||||
"title": "デイリーメモリー",
|
||||
"sectionTitle": "デイリーメモリー",
|
||||
"cardTitle": "デイリーメモリーファイル",
|
||||
"cardDescription": "デイリーメモリーの閲覧・管理",
|
||||
"createToday": "メモリーを追加",
|
||||
"createToday": "今日のノート",
|
||||
"empty": "デイリーメモリーファイルはまだありません",
|
||||
"loadFailed": "デイリーメモリーファイルの読み込みに失敗しました",
|
||||
"createFailed": "デイリーメモリーファイルの作成に失敗しました",
|
||||
"deleteSuccess": "デイリーメモリーファイルを削除しました",
|
||||
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
|
||||
"confirmDeleteTitle": "デイリーメモリーを削除",
|
||||
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。",
|
||||
"searchPlaceholder": "全文検索...",
|
||||
"searchScopeHint": "すべてのデイリーメモリーを全文検索 ⌘F",
|
||||
"searchCloseHint": "Escで閉じる",
|
||||
"noSearchResults": "検索に一致するデイリーメモリーはありません。",
|
||||
"searching": "検索中...",
|
||||
"searchFailed": "検索に失敗しました",
|
||||
"matchCount": "{{count}}件一致"
|
||||
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。"
|
||||
}
|
||||
},
|
||||
"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キー、カスタム変数など)",
|
||||
@@ -1383,7 +1338,6 @@
|
||||
"http429": "リクエストが多すぎます。時間をおいて再試行してください",
|
||||
"parseMetadataFailed": "スキルメタデータの解析に失敗しました",
|
||||
"getHomeDirFailed": "ユーザーのホームディレクトリを取得できません",
|
||||
"noSkillsInZip": "ZIP ファイルにスキルが見つかりません(SKILL.md ファイルが必要です)",
|
||||
"networkError": "ネットワークエラー",
|
||||
"fsError": "ファイルシステムエラー",
|
||||
"unknownError": "不明なエラー",
|
||||
@@ -1394,8 +1348,7 @@
|
||||
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
|
||||
"checkDiskSpace": "ディスク容量を確認してください",
|
||||
"checkPermission": "ディレクトリの権限を確認してください",
|
||||
"uninstallFirst": "同名のスキルを先にアンインストールしてください",
|
||||
"checkZipContent": "ZIP ファイルに有効なスキルディレクトリ(SKILL.md を含む)が含まれていることを確認してください"
|
||||
"uninstallFirst": "同名のスキルを先にアンインストールしてください"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
@@ -1582,7 +1535,7 @@
|
||||
"currentProvider": "現在のプロバイダー:",
|
||||
"waitingFirstRequest": "現在のプロバイダー: 最初のリクエスト待ち...",
|
||||
"stoppedTitle": "プロキシサービス停止中",
|
||||
"stoppedDescription": "上のトグルでサービスを開始できます",
|
||||
"stoppedDescription": "右上のトグルでサービスを開始できます",
|
||||
"openSettings": "プロキシサービスを設定",
|
||||
"stats": {
|
||||
"activeConnections": "アクティブ接続",
|
||||
@@ -1665,20 +1618,9 @@
|
||||
"toast": {
|
||||
"saved": "プロキシ設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
},
|
||||
"invalidPort": "無効なポートです。1024〜65535 の数値を入力してください",
|
||||
"invalidAddress": "無効なアドレスです。有効な IP アドレス(例: 127.0.0.1)または localhost を入力してください",
|
||||
"configSaved": "プロキシ設定を保存しました",
|
||||
"configSaveFailed": "設定の保存に失敗しました",
|
||||
"restartRequired": "アドレスまたはポートの変更を反映するにはプロキシサービスの再起動が必要です"
|
||||
}
|
||||
},
|
||||
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||
"takeover": {
|
||||
"hint": "テイクオーバーするアプリを選択します。有効にすると、そのアプリのリクエストはローカルプロキシ経由で転送されます",
|
||||
"enabled": "{{app}} テイクオーバー有効",
|
||||
"disabled": "{{app}} テイクオーバー無効",
|
||||
"failed": "テイクオーバーの切り替えに失敗しました"
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります",
|
||||
"autoSwitch": "自動フェイルオーバー",
|
||||
@@ -1706,7 +1648,6 @@
|
||||
"info": "フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。",
|
||||
"configSaved": "自動フェイルオーバー設定を保存しました",
|
||||
"configSaveFailed": "保存に失敗しました",
|
||||
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
|
||||
"retrySettings": "リトライとタイムアウト設定",
|
||||
"failureThreshold": "失敗しきい値",
|
||||
"failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)",
|
||||
@@ -1759,16 +1700,6 @@
|
||||
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
||||
"nonStreaming": "非ストリーミングタイムアウト"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "失敗しきい値",
|
||||
"successThreshold": "成功しきい値",
|
||||
"timeoutSeconds": "タイムアウト(秒)",
|
||||
"errorRateThreshold": "エラー率しきい値",
|
||||
"minRequests": "最小リクエスト数",
|
||||
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
|
||||
"configSaved": "サーキットブレーカー設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました"
|
||||
},
|
||||
"universalProvider": {
|
||||
"title": "統合プロバイダー",
|
||||
"description": "統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。",
|
||||
@@ -1793,7 +1724,6 @@
|
||||
"syncError": "同期に失敗しました",
|
||||
"noAppsEnabled": "有効なアプリがありません",
|
||||
"added": "統合プロバイダーを追加しました",
|
||||
"addedAndSynced": "統合プロバイダーを追加して同期しました",
|
||||
"updated": "統合プロバイダーを更新しました",
|
||||
"deleted": "統合プロバイダーを削除しました",
|
||||
"addSuccess": "統合プロバイダーを追加しました",
|
||||
@@ -1846,6 +1776,10 @@
|
||||
"enabled": "有効中",
|
||||
"disabled": "OMO を無効化しました",
|
||||
"disableFailed": "OMO の無効化に失敗しました: {{error}}",
|
||||
"writeCommonConfig": "共通設定に書き込む",
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "共通設定",
|
||||
"commonConfigHint": "OMO 共通設定は有効にしたすべての OMO 設定に統合されます",
|
||||
"selectPlaceholder": "選択してください...",
|
||||
"clear": "クリア",
|
||||
"clearWrapped": "(クリア)",
|
||||
@@ -1875,10 +1809,6 @@
|
||||
"customCategories": "カスタムカテゴリ",
|
||||
"modelConfiguration": "モデル設定",
|
||||
"fillRecommended": "推奨を入力",
|
||||
"fillRecommendedSuccess": "推奨モデル {{count}} 件を設定しました",
|
||||
"fillRecommendedPartial": "推奨モデル {{filled}} 件を設定、{{unmatched}} 件は未一致",
|
||||
"fillRecommendedAllSet": "すべてのスロットにモデルが設定済みです",
|
||||
"fillRecommendedNoMatch": "推奨モデルが設定済みプロバイダーに見つかりませんでした",
|
||||
"configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
|
||||
"enabledModelsCount": "設定済みモデル {{count}} 件",
|
||||
"source": "出典:",
|
||||
|
||||
+46
-97
@@ -51,15 +51,7 @@
|
||||
},
|
||||
"claudeConfig": {
|
||||
"configLabel": "Claude Code 配置 (JSON) *",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "编辑通用配置片段",
|
||||
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
|
||||
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
||||
"extractFromCurrent": "从编辑内容提取",
|
||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
||||
"extractFailed": "提取失败: {{error}}",
|
||||
"saveFailed": "保存失败: {{error}}",
|
||||
"hideAttribution": "隐藏 AI 署名",
|
||||
"alwaysThinking": "扩展思考",
|
||||
"enableTeammates": "Teammates 模式"
|
||||
@@ -125,11 +117,7 @@
|
||||
"notes": "备注",
|
||||
"notesPlaceholder": "例如:公司专用账号",
|
||||
"configJson": "配置 JSON",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"editCommonConfigButton": "编辑通用配置",
|
||||
"configJsonHint": "请填写完整的 Claude Code 配置",
|
||||
"editCommonConfigTitle": "编辑通用配置片段",
|
||||
"editCommonConfigHint": "通用配置片段将合并到所有启用它的供应商配置中",
|
||||
"addProvider": "添加供应商",
|
||||
"sortUpdated": "排序已更新",
|
||||
"usageSaved": "用量查询配置已保存",
|
||||
@@ -319,17 +307,7 @@
|
||||
"rename": "重命名",
|
||||
"renameSuccess": "备份重命名成功",
|
||||
"renameFailed": "重命名失败",
|
||||
"namePlaceholder": "输入新名称",
|
||||
"createBackup": "立即备份",
|
||||
"creating": "备份中...",
|
||||
"createSuccess": "备份创建成功",
|
||||
"createFailed": "备份创建失败",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中...",
|
||||
"deleteSuccess": "备份已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"deleteConfirmTitle": "确认删除备份",
|
||||
"deleteConfirmMessage": "此备份将被永久删除,此操作无法撤消。"
|
||||
"namePlaceholder": "输入新名称"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV 云同步",
|
||||
@@ -647,10 +625,7 @@
|
||||
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
|
||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
|
||||
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!",
|
||||
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!",
|
||||
"aicoding": "AI Coding 为 CC Switch 的用户提供了特殊优惠,首次充值可以享受 9 折优惠!",
|
||||
"crazyrouter": "CrazyRouter 为 CC Switch 的用户提供了特殊优惠,首次充值赠予 30% 额外额度!",
|
||||
"sssaicode": "SSAI Code 为 CC Switch 的用户提供了特殊优惠,每次充值赠予额外 $10 额度!"
|
||||
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!"
|
||||
},
|
||||
"parameterConfig": "参数配置 - {{name}} *",
|
||||
"mainModel": "主模型 (可选)",
|
||||
@@ -680,6 +655,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 默认模型",
|
||||
@@ -751,33 +730,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}}"
|
||||
@@ -813,6 +774,38 @@
|
||||
"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": "自定义配置",
|
||||
@@ -1200,60 +1193,22 @@
|
||||
"saveSuccess": "保存成功",
|
||||
"saveFailed": "保存失败",
|
||||
"loadFailed": "读取失败",
|
||||
"openDirectory": "在文件管理器中打开",
|
||||
"dailyMemory": {
|
||||
"title": "每日记忆",
|
||||
"sectionTitle": "每日记忆",
|
||||
"cardTitle": "每日记忆文件",
|
||||
"cardDescription": "浏览管理每日记忆",
|
||||
"createToday": "添加记忆",
|
||||
"createToday": "今日笔记",
|
||||
"empty": "暂无每日记忆文件",
|
||||
"loadFailed": "加载每日记忆文件失败",
|
||||
"createFailed": "创建每日记忆文件失败",
|
||||
"deleteSuccess": "每日记忆文件已删除",
|
||||
"deleteFailed": "删除每日记忆文件失败",
|
||||
"confirmDeleteTitle": "删除每日记忆",
|
||||
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。",
|
||||
"searchPlaceholder": "搜索全文内容...",
|
||||
"searchScopeHint": "全文搜索所有每日记忆 ⌘F",
|
||||
"searchCloseHint": "Esc 关闭",
|
||||
"noSearchResults": "没有找到匹配的每日记忆。",
|
||||
"searching": "搜索中...",
|
||||
"searchFailed": "搜索失败",
|
||||
"matchCount": "{{count}} 处匹配"
|
||||
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。"
|
||||
}
|
||||
},
|
||||
"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、自定义变量等)",
|
||||
@@ -1582,7 +1537,7 @@
|
||||
"currentProvider": "当前 Provider:",
|
||||
"waitingFirstRequest": "当前 Provider:等待首次请求…",
|
||||
"stoppedTitle": "代理服务已停止",
|
||||
"stoppedDescription": "使用上方开关即可启动服务",
|
||||
"stoppedDescription": "使用右上角开关即可启动服务",
|
||||
"openSettings": "配置代理服务",
|
||||
"stats": {
|
||||
"activeConnections": "活跃连接",
|
||||
@@ -1673,12 +1628,6 @@
|
||||
"restartRequired": "修改地址或端口后需要重启代理服务才能生效"
|
||||
},
|
||||
"switchFailed": "切换失败: {{error}}",
|
||||
"takeover": {
|
||||
"hint": "选择要接管的应用,启用后该应用的请求将通过本地代理转发",
|
||||
"enabled": "{{app}} 接管已启用",
|
||||
"disabled": "{{app}} 接管已关闭",
|
||||
"failed": "切换接管状态失败"
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "需要先启动代理服务才能配置故障转移",
|
||||
"autoSwitch": "自动故障转移",
|
||||
@@ -1846,6 +1795,10 @@
|
||||
"enabled": "启用中",
|
||||
"disabled": "OMO 已停用",
|
||||
"disableFailed": "停用 OMO 失败: {{error}}",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "通用配置",
|
||||
"commonConfigHint": "OMO 通用配置将合并到所有启用它的 OMO 配置中",
|
||||
"selectPlaceholder": "请选择...",
|
||||
"clear": "清空",
|
||||
"clearWrapped": "(清空)",
|
||||
@@ -1875,10 +1828,6 @@
|
||||
"customCategories": "自定义分类",
|
||||
"modelConfiguration": "模型配置",
|
||||
"fillRecommended": "填充推荐",
|
||||
"fillRecommendedSuccess": "已填充 {{count}} 个推荐模型",
|
||||
"fillRecommendedPartial": "已填充 {{filled}} 个推荐模型,{{unmatched}} 个未匹配",
|
||||
"fillRecommendedAllSet": "所有槽位已有模型配置",
|
||||
"fillRecommendedNoMatch": "推荐模型在已配置的供应商中未找到匹配",
|
||||
"configSummary": "已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
|
||||
"enabledModelsCount": "可选已配置模型 {{count}} 个",
|
||||
"source": "来源:",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 24 KiB |
@@ -3,7 +3,6 @@
|
||||
|
||||
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>`,
|
||||
@@ -16,7 +15,6 @@ 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>`,
|
||||
@@ -60,7 +58,6 @@ 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>`,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -3,27 +3,6 @@ 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)
|
||||
@@ -47,31 +26,3 @@ export async function setCommonConfigSnippet(
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"),
|
||||
};
|
||||
|
||||
@@ -13,5 +14,7 @@ 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"),
|
||||
};
|
||||
|
||||
@@ -110,14 +110,6 @@ export const providersApi = {
|
||||
async getOpenClawLiveProviderIds(): Promise<string[]> {
|
||||
return await invoke("get_openclaw_live_provider_ids");
|
||||
},
|
||||
|
||||
/**
|
||||
* 从 OpenClaw live 配置导入供应商到数据库
|
||||
* OpenClaw 特有功能:由于累加模式,用户可能已在 openclaw.json 中配置供应商
|
||||
*/
|
||||
async importOpenClawFromLive(): Promise<number> {
|
||||
return await invoke("import_openclaw_providers_from_live");
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -223,10 +223,6 @@ 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");
|
||||
},
|
||||
@@ -238,8 +234,4 @@ 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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,15 +8,6 @@ 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 });
|
||||
@@ -41,16 +32,4 @@ 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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,12 +19,8 @@ export const useAddProviderMutation = (appId: AppId) => {
|
||||
let id: string;
|
||||
|
||||
if (appId === "opencode" || appId === "openclaw") {
|
||||
if (
|
||||
providerInput.category === "omo" ||
|
||||
providerInput.category === "omo-slim"
|
||||
) {
|
||||
const prefix = providerInput.category === "omo" ? "omo" : "omo-slim";
|
||||
id = `${prefix}-${generateUUID()}`;
|
||||
if (providerInput.category === "omo") {
|
||||
id = `omo-${generateUUID()}`;
|
||||
} else {
|
||||
if (!providerInput.providerKey) {
|
||||
throw new Error(`Provider key is required for ${appId}`);
|
||||
@@ -57,12 +53,6 @@ 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 {
|
||||
@@ -145,12 +135,6 @@ 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 {
|
||||
@@ -202,9 +186,6 @@ 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({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user