mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83fe3402c2 | |||
| c75311e14e | |||
| 35a4a15898 | |||
| fd836ce70d | |||
| d5e4e8d133 | |||
| f8c1f1736e | |||
| 859f413756 | |||
| b2b20dadd7 | |||
| 3bd0a7c02c | |||
| ac23328119 | |||
| 0c4a8d0569 | |||
| 01cc766a05 | |||
| 3590df68b8 | |||
| 54876612b3 | |||
| 3348b39089 | |||
| 99e392518a | |||
| 201527c9d9 | |||
| 924f38ebe1 | |||
| e7766d4d22 | |||
| 3e9815f5d2 | |||
| 082cb0327d | |||
| 55e21c3c19 | |||
| 3e2c8c12a5 | |||
| 90cb8db16b | |||
| 2b30819510 | |||
| 434392a669 | |||
| 24fa8a18ef | |||
| 87604b1809 | |||
| 47435bd647 | |||
| 73ca8340bb | |||
| 31712f076f | |||
| a254304a00 | |||
| e516f83c19 | |||
| b98c3ddb29 | |||
| bb3130cbe0 | |||
| 8ea9638b9d | |||
| e1e2bdef2a | |||
| c380528a27 | |||
| a8dbea1398 | |||
| 7138aca310 | |||
| 992dda5c5c | |||
| 1b20b7ff88 | |||
| f8820aa22c | |||
| 3afec8a10f | |||
| 5ebc879f09 | |||
| 6b4ba64bbd | |||
| 5763b9094b | |||
| ce92f37ef0 | |||
| 2e676e5f53 | |||
| c3f29a62d1 | |||
| 165af5eec4 | |||
| 8b9c09d994 | |||
| a8d391bd74 | |||
| 7d9b20721e | |||
| d11df17b5d | |||
| 4c88174cb0 | |||
| 5a72888852 | |||
| 54f1bfb5d0 | |||
| 4efab15174 | |||
| 357d32c17e | |||
| 7532308519 | |||
| 4440a14082 | |||
| d1bb4480db | |||
| d04c279890 | |||
| 3125b5419c | |||
| 0fa6b33b5e | |||
| 1b71dc721c | |||
| 8e219b5eb1 | |||
| 51476953ae | |||
| 0f4ce74916 | |||
| 9514d08ef6 |
@@ -23,3 +23,4 @@ nul
|
||||
flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
.worktrees/
|
||||
|
||||
+204
-1
@@ -7,9 +7,212 @@ 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
|
||||
|
||||
- **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.
|
||||
- **"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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
### Current Version: v3.11.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.11.1-en.md)
|
||||
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
**Core Capabilities**
|
||||
|
||||
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
||||
- **AWS Bedrock Support**: Built-in AWS Bedrock provider presets with AKSK and API Key authentication, cross-region inference support (global/us/eu/apac), covering Claude Code and OpenCode
|
||||
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
||||
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
||||
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
|
||||
|
||||
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
**コア機能**
|
||||
|
||||
- **プロバイダ管理**:Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
|
||||
- **AWS Bedrock 対応**:AWS Bedrock プロバイダプリセットを内蔵、AKSK および API Key 認証に対応、クロスリージョン推論(global/us/eu/apac)をサポート、Claude Code と OpenCode に対応
|
||||
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
|
||||
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
|
||||
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
|
||||
|
||||
@@ -157,6 +157,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
**核心功能**
|
||||
|
||||
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
||||
- **AWS Bedrock 支持**:内置 AWS Bedrock 供应商预设,支持 AKSK 和 API Key 两种认证方式,支持跨区域推理(global/us/eu/apac),覆盖 Claude Code 和 OpenCode
|
||||
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
||||
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
||||
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# 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` |
|
||||
@@ -0,0 +1,302 @@
|
||||
# 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` |
|
||||
@@ -0,0 +1,302 @@
|
||||
# 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` |
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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` |
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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` |
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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.10.3
|
||||
- 最后更新:2026-02-09
|
||||
- 适用于 CC Switch v3.10.0+
|
||||
- 文档版本:v3.11.1
|
||||
- 最后更新:2026-02-28
|
||||
- 适用于 CC Switch v3.11.1+
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.10.3",
|
||||
"version": "3.11.1",
|
||||
"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.10.3"
|
||||
version = "3.11.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.10.3"
|
||||
version = "3.11.1"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -215,7 +215,7 @@ pub async fn set_common_config_snippet(
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" => {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
@@ -238,12 +238,28 @@ pub async fn set_common_config_snippet(
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode")
|
||||
.get_current_omo_provider("opencode", "omo")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(state.inner())
|
||||
.map_err(|e| e.to_string())?;
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::STANDARD,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if app_type == "omo-slim"
|
||||
&& state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")
|
||||
.map_err(|e| e.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state.inner(),
|
||||
&crate::services::omo::SLIM,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use tauri_plugin_dialog::DialogExt;
|
||||
use crate::commands::sync_support::{
|
||||
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
||||
};
|
||||
use crate::database::backup::BackupEntry;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::provider::ProviderService;
|
||||
use crate::store::AppState;
|
||||
@@ -118,3 +120,57 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
// ─── Database backup management ─────────────────────────────
|
||||
|
||||
/// Manually create a database backup
|
||||
#[tauri::command]
|
||||
pub async fn create_db_backup(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let db = state.db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || match db.backup_database_file()? {
|
||||
Some(path) => Ok(path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()),
|
||||
None => Err(AppError::Config(
|
||||
"Database file not found, backup skipped".to_string(),
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Backup failed: {e}"))?
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
}
|
||||
|
||||
/// List all database backup files
|
||||
#[tauri::command]
|
||||
pub fn list_db_backups() -> Result<Vec<BackupEntry>, String> {
|
||||
Database::list_backups().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Restore database from a backup file
|
||||
#[tauri::command]
|
||||
pub async fn restore_db_backup(
|
||||
state: State<'_, AppState>,
|
||||
filename: String,
|
||||
) -> Result<String, String> {
|
||||
let db = state.db.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename))
|
||||
.await
|
||||
.map_err(|e| format!("Restore failed: {e}"))?
|
||||
.map_err(|e: AppError| e.to_string())
|
||||
}
|
||||
|
||||
/// Rename a database backup file
|
||||
#[tauri::command]
|
||||
pub fn rename_db_backup(
|
||||
#[allow(non_snake_case)] oldFilename: String,
|
||||
#[allow(non_snake_case)] newName: String,
|
||||
) -> Result<String, String> {
|
||||
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete a database backup file
|
||||
#[tauri::command]
|
||||
pub fn delete_db_backup(filename: String) -> Result<(), String> {
|
||||
Database::delete_backup(&filename).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
+254
-49
@@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
||||
use crate::services::ProviderService;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use tauri::AppHandle;
|
||||
@@ -85,50 +86,135 @@ pub struct ToolVersion {
|
||||
version: Option<String>,
|
||||
latest_version: Option<String>, // 新增字段:最新版本
|
||||
error: Option<String>,
|
||||
/// 工具运行环境: "windows", "wsl", "macos", "linux", "unknown"
|
||||
env_type: String,
|
||||
/// 当 env_type 为 "wsl" 时,返回该工具绑定的 WSL distro(用于按 distro 探测 shells)
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
const VALID_TOOLS: [&str; 4] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WslShellPreferenceInput {
|
||||
#[serde(default)]
|
||||
pub wsl_shell: Option<String>,
|
||||
#[serde(default)]
|
||||
pub wsl_shell_flag: Option<String>,
|
||||
}
|
||||
|
||||
// Keep platform-specific env detection in one place to avoid repeating cfg blocks.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn tool_env_type_and_wsl_distro(tool: &str) -> (String, Option<String>) {
|
||||
if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
("wsl".to_string(), Some(distro))
|
||||
} else {
|
||||
("windows".to_string(), None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("macos".to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("linux".to_string(), None)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
fn tool_env_type_and_wsl_distro(_tool: &str) -> (String, Option<String>) {
|
||||
("unknown".to_string(), None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
||||
let mut results = Vec::new();
|
||||
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());
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单个工具的版本信息(内部实现)
|
||||
async fn get_single_tool_version_impl(
|
||||
tool: &str,
|
||||
wsl_shell: Option<&str>,
|
||||
wsl_shell_flag: Option<&str>,
|
||||
) -> ToolVersion {
|
||||
debug_assert!(
|
||||
VALID_TOOLS.contains(&tool),
|
||||
"unexpected tool name in get_single_tool_version_impl: {tool}"
|
||||
);
|
||||
|
||||
// 判断该工具的运行环境 & WSL distro(如有)
|
||||
let (env_type, wsl_distro) = tool_env_type_and_wsl_distro(tool);
|
||||
|
||||
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
for tool in tools {
|
||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
try_get_version_wsl(tool, &distro)
|
||||
// 1. 获取本地版本
|
||||
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
|
||||
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
|
||||
} else {
|
||||
let direct_result = try_get_version(tool);
|
||||
if direct_result.0.is_some() {
|
||||
direct_result
|
||||
} else {
|
||||
// 先尝试直接执行
|
||||
let direct_result = try_get_version(tool);
|
||||
scan_cli_version(tool)
|
||||
}
|
||||
};
|
||||
|
||||
if direct_result.0.is_some() {
|
||||
direct_result
|
||||
} else {
|
||||
// 扫描常见的 npm 全局安装路径
|
||||
scan_cli_version(tool)
|
||||
}
|
||||
};
|
||||
// 2. 获取远程最新版本
|
||||
let latest_version = match tool {
|
||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// 2. 获取远程最新版本
|
||||
let latest_version = match tool {
|
||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
results.push(ToolVersion {
|
||||
name: tool.to_string(),
|
||||
version: local_version,
|
||||
latest_version,
|
||||
error: local_error,
|
||||
});
|
||||
ToolVersion {
|
||||
name: tool.to_string(),
|
||||
version: local_version,
|
||||
latest_version,
|
||||
error: local_error,
|
||||
env_type,
|
||||
wsl_distro,
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Helper function to fetch latest version from npm registry
|
||||
@@ -242,8 +328,38 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// Validate that the given shell name is one of the allowed shells.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<String>) {
|
||||
fn is_valid_shell(shell: &str) -> bool {
|
||||
matches!(
|
||||
shell.rsplit('/').next().unwrap_or(shell),
|
||||
"sh" | "bash" | "zsh" | "fish" | "dash"
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate that the given shell flag is one of the allowed flags.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_shell_flag(flag: &str) -> bool {
|
||||
matches!(flag, "-c" | "-lc" | "-lic")
|
||||
}
|
||||
|
||||
/// Return the default invocation flag for the given shell.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn default_flag_for_shell(shell: &str) -> &'static str {
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"dash" | "sh" => "-c",
|
||||
"fish" => "-lc",
|
||||
_ => "-lic",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn try_get_version_wsl(
|
||||
tool: &str,
|
||||
distro: &str,
|
||||
force_shell: Option<&str>,
|
||||
force_shell_flag: Option<&str>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
// 防御性断言:tool 只能是预定义的值
|
||||
@@ -257,15 +373,47 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
||||
}
|
||||
|
||||
// 构建 Shell 脚本检测逻辑
|
||||
let (shell, flag, cmd) = if let Some(shell) = force_shell {
|
||||
// Defensive validation: never allow an arbitrary executable name here.
|
||||
if !is_valid_shell(shell) {
|
||||
return (None, Some(format!("[WSL:{distro}] invalid shell: {shell}")));
|
||||
}
|
||||
let shell = shell.rsplit('/').next().unwrap_or(shell);
|
||||
let flag = if let Some(flag) = force_shell_flag {
|
||||
if !is_valid_shell_flag(flag) {
|
||||
return (
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||
);
|
||||
}
|
||||
flag
|
||||
} else {
|
||||
default_flag_for_shell(shell)
|
||||
};
|
||||
|
||||
(shell.to_string(), flag, format!("{tool} --version"))
|
||||
} else {
|
||||
let cmd = if let Some(flag) = force_shell_flag {
|
||||
if !is_valid_shell_flag(flag) {
|
||||
return (
|
||||
None,
|
||||
Some(format!("[WSL:{distro}] invalid shell flag: {flag}")),
|
||||
);
|
||||
}
|
||||
format!("\"${{SHELL:-sh}}\" {flag} '{tool} --version'")
|
||||
} else {
|
||||
// 兜底:自动尝试 -lic, -lc, -c
|
||||
format!(
|
||||
"\"${{SHELL:-sh}}\" -lic '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -lc '{tool} --version' 2>/dev/null || \"${{SHELL:-sh}}\" -c '{tool} --version'"
|
||||
)
|
||||
};
|
||||
|
||||
("sh".to_string(), "-c", cmd)
|
||||
};
|
||||
|
||||
let output = Command::new("wsl.exe")
|
||||
.args([
|
||||
"-d",
|
||||
distro,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
&format!("{tool} --version"),
|
||||
])
|
||||
.args(["-d", distro, "--", &shell, flag, &cmd])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
|
||||
@@ -306,7 +454,12 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option<String>, Option<String>) {
|
||||
fn try_get_version_wsl(
|
||||
_tool: &str,
|
||||
_distro: &str,
|
||||
_force_shell: Option<&str>,
|
||||
_force_shell_flag: Option<&str>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
(
|
||||
None,
|
||||
Some("WSL check not supported on this platform".to_string()),
|
||||
@@ -517,6 +670,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
(None, Some("not installed or not executable".to_string()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||
let override_dir = match tool {
|
||||
"claude" => crate::settings::get_claude_override_dir(),
|
||||
@@ -554,12 +708,6 @@ fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows 平台不支持 WSL 路径解析
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 打开指定提供商的终端
|
||||
///
|
||||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||
@@ -1061,6 +1209,63 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_extract_version() {
|
||||
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
|
||||
assert_eq!(extract_version("v2.3.4-beta.1"), "2.3.4-beta.1");
|
||||
assert_eq!(extract_version("no version here"), "no version here");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod wsl_helpers {
|
||||
use super::super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_shell() {
|
||||
assert!(is_valid_shell("bash"));
|
||||
assert!(is_valid_shell("zsh"));
|
||||
assert!(is_valid_shell("sh"));
|
||||
assert!(is_valid_shell("fish"));
|
||||
assert!(is_valid_shell("dash"));
|
||||
assert!(is_valid_shell("/usr/bin/bash"));
|
||||
assert!(is_valid_shell("/bin/zsh"));
|
||||
assert!(!is_valid_shell("powershell"));
|
||||
assert!(!is_valid_shell("cmd"));
|
||||
assert!(!is_valid_shell(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_shell_flag() {
|
||||
assert!(is_valid_shell_flag("-c"));
|
||||
assert!(is_valid_shell_flag("-lc"));
|
||||
assert!(is_valid_shell_flag("-lic"));
|
||||
assert!(!is_valid_shell_flag("-x"));
|
||||
assert!(!is_valid_shell_flag(""));
|
||||
assert!(!is_valid_shell_flag("--login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_flag_for_shell() {
|
||||
assert_eq!(default_flag_for_shell("sh"), "-c");
|
||||
assert_eq!(default_flag_for_shell("dash"), "-c");
|
||||
assert_eq!(default_flag_for_shell("/bin/dash"), "-c");
|
||||
assert_eq!(default_flag_for_shell("fish"), "-lc");
|
||||
assert_eq!(default_flag_for_shell("bash"), "-lic");
|
||||
assert_eq!(default_flag_for_shell("zsh"), "-lic");
|
||||
assert_eq!(default_flag_for_shell("/usr/bin/zsh"), "-lic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_wsl_distro_name() {
|
||||
assert!(is_valid_wsl_distro_name("Ubuntu"));
|
||||
assert!(is_valid_wsl_distro_name("Ubuntu-22.04"));
|
||||
assert!(is_valid_wsl_distro_name("my_distro"));
|
||||
assert!(!is_valid_wsl_distro_name(""));
|
||||
assert!(!is_valid_wsl_distro_name("distro with spaces"));
|
||||
assert!(!is_valid_wsl_distro_name(&"a".repeat(65)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::services::omo::OmoLocalFileData;
|
||||
use crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};
|
||||
use crate::services::OmoService;
|
||||
use crate::store::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
|
||||
OmoService::read_local_file().map_err(|e| e.to_string())
|
||||
OmoService::read_local_file(&STANDARD).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let provider = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode")
|
||||
.get_current_omo_provider("opencode", "omo")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(provider.map(|p| p.id).unwrap_or_default())
|
||||
}
|
||||
@@ -28,23 +28,46 @@ pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), Strin
|
||||
if p.category.as_deref() == Some("omo") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current("opencode", id)
|
||||
.clear_omo_provider_current("opencode", id, "omo")
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
OmoService::delete_config_file().map_err(|e| e.to_string())?;
|
||||
OmoService::delete_config_file(&STANDARD).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── OMO Slim commands ───────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result<usize, String> {
|
||||
pub async fn read_omo_slim_local_file() -> Result<OmoLocalFileData, String> {
|
||||
OmoService::read_local_file(&SLIM).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_omo_slim_provider_id(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let provider = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(provider.map(|p| p.id).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers("opencode")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let count = providers
|
||||
.values()
|
||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
||||
.count();
|
||||
Ok(count)
|
||||
for (id, p) in &providers {
|
||||
if p.category.as_deref() == Some("omo-slim") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current("opencode", id, "omo-slim")
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ use tauri::State;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
||||
use crate::services::{
|
||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -67,7 +69,11 @@ pub fn remove_provider_from_live_config(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
fn switch_provider_internal(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
id: &str,
|
||||
) -> Result<SwitchResult, AppError> {
|
||||
ProviderService::switch(state, app_type, id)
|
||||
}
|
||||
|
||||
@@ -76,7 +82,7 @@ pub fn switch_provider_test_hook(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
) -> Result<SwitchResult, AppError> {
|
||||
switch_provider_internal(state, app_type, id)
|
||||
}
|
||||
|
||||
@@ -85,15 +91,35 @@ pub fn switch_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<SwitchResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
switch_provider_internal(&state, app_type, &id)
|
||||
.map(|_| true)
|
||||
.map_err(|e| e.to_string())
|
||||
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
ProviderService::import_default_config(state, app_type)
|
||||
let imported = ProviderService::import_default_config(state, app_type.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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
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;
|
||||
|
||||
@@ -24,6 +29,281 @@ fn validate_filename(filename: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Daily memory files (memory/YYYY-MM-DD.md) ---
|
||||
|
||||
static DAILY_MEMORY_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap());
|
||||
|
||||
fn validate_daily_memory_filename(filename: &str) -> Result<(), String> {
|
||||
if !DAILY_MEMORY_RE.is_match(filename) {
|
||||
return Err(format!(
|
||||
"Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DailyMemoryFileInfo {
|
||||
pub filename: String,
|
||||
pub date: String,
|
||||
pub size_bytes: u64,
|
||||
pub modified_at: u64,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
// --- Daily memory commands ---
|
||||
|
||||
/// List all daily memory files under `workspace/memory/`.
|
||||
#[tauri::command]
|
||||
pub async fn list_daily_memory_files() -> Result<Vec<DailyMemoryFileInfo>, String> {
|
||||
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||
|
||||
if !memory_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut files: Vec<DailyMemoryFileInfo> = Vec::new();
|
||||
|
||||
let entries = std::fs::read_dir(&memory_dir)
|
||||
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let date = name.trim_end_matches(".md").to_string();
|
||||
|
||||
let size_bytes = meta.len();
|
||||
let modified_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let preview = std::fs::read_to_string(entry.path())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.take(200)
|
||||
.collect::<String>();
|
||||
|
||||
files.push(DailyMemoryFileInfo {
|
||||
filename: name,
|
||||
date,
|
||||
size_bytes,
|
||||
modified_at,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by filename descending (newest date first, YYYY-MM-DD.md)
|
||||
files.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Read a daily memory file.
|
||||
#[tauri::command]
|
||||
pub async fn read_daily_memory_file(filename: String) -> Result<Option<String>, String> {
|
||||
validate_daily_memory_filename(&filename)?;
|
||||
|
||||
let path = get_openclaw_dir()
|
||||
.join("workspace")
|
||||
.join("memory")
|
||||
.join(&filename);
|
||||
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
std::fs::read_to_string(&path)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to read daily memory file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Write a daily memory file (atomic write).
|
||||
#[tauri::command]
|
||||
pub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> {
|
||||
validate_daily_memory_filename(&filename)?;
|
||||
|
||||
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||
|
||||
std::fs::create_dir_all(&memory_dir)
|
||||
.map_err(|e| format!("Failed to create memory directory: {e}"))?;
|
||||
|
||||
let path = memory_dir.join(&filename);
|
||||
|
||||
write_text_file(&path, &content)
|
||||
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
|
||||
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
|
||||
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
while !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
|
||||
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
|
||||
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
while !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Search result for daily memory full-text search.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DailyMemorySearchResult {
|
||||
pub filename: String,
|
||||
pub date: String,
|
||||
pub size_bytes: u64,
|
||||
pub modified_at: u64,
|
||||
pub snippet: String,
|
||||
pub match_count: usize,
|
||||
}
|
||||
|
||||
/// Full-text search across all daily memory files.
|
||||
///
|
||||
/// Performs case-insensitive search on both the date field and file content.
|
||||
/// Returns results sorted by filename descending (newest first), each with a
|
||||
/// snippet showing ~120 characters of context around the first match.
|
||||
#[tauri::command]
|
||||
pub async fn search_daily_memory_files(
|
||||
query: String,
|
||||
) -> Result<Vec<DailyMemorySearchResult>, String> {
|
||||
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||
|
||||
if !memory_dir.exists() || query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<DailyMemorySearchResult> = Vec::new();
|
||||
|
||||
let entries = std::fs::read_dir(&memory_dir)
|
||||
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) if m.is_file() => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let date = name.trim_end_matches(".md").to_string();
|
||||
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
||||
let content_lower = content.to_lowercase();
|
||||
|
||||
// Count matches in content
|
||||
let content_matches: Vec<usize> = content_lower
|
||||
.match_indices(&query_lower)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Also check date field
|
||||
let date_matches = date.to_lowercase().contains(&query_lower);
|
||||
|
||||
if content_matches.is_empty() && !date_matches {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build snippet around first content match (~120 chars of context)
|
||||
let snippet = if let Some(&first_pos) = content_matches.first() {
|
||||
let start = if first_pos > 50 {
|
||||
floor_char_boundary(&content, first_pos - 50)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
|
||||
let mut s = String::new();
|
||||
if start > 0 {
|
||||
s.push_str("...");
|
||||
}
|
||||
s.push_str(&content[start..end]);
|
||||
if end < content.len() {
|
||||
s.push_str("...");
|
||||
}
|
||||
s
|
||||
} else {
|
||||
// Date-only match — use beginning of file as preview
|
||||
let end = ceil_char_boundary(&content, 120.min(content.len()));
|
||||
let mut s = content[..end].to_string();
|
||||
if end < content.len() {
|
||||
s.push_str("...");
|
||||
}
|
||||
s
|
||||
};
|
||||
|
||||
let size_bytes = meta.len();
|
||||
let modified_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
results.push(DailyMemorySearchResult {
|
||||
filename: name,
|
||||
date,
|
||||
size_bytes,
|
||||
modified_at,
|
||||
snippet,
|
||||
match_count: content_matches.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by filename descending (newest date first)
|
||||
results.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Delete a daily memory file (idempotent).
|
||||
#[tauri::command]
|
||||
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
|
||||
validate_daily_memory_filename(&filename)?;
|
||||
|
||||
let path = get_openclaw_dir()
|
||||
.join("workspace")
|
||||
.join("memory")
|
||||
.join(&filename);
|
||||
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to delete daily memory file {filename}: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Workspace file commands ---
|
||||
|
||||
/// Read an OpenClaw workspace file content.
|
||||
/// Returns None if the file does not exist.
|
||||
#[tauri::command]
|
||||
@@ -58,3 +338,25 @@ pub async fn write_workspace_file(filename: String, content: String) -> Result<(
|
||||
write_text_file(&path, &content)
|
||||
.map_err(|e| format!("Failed to write workspace file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Open the workspace or memory directory in the system file manager.
|
||||
/// `subdir`: "workspace" opens `~/.openclaw/workspace/`,
|
||||
/// "memory" opens `~/.openclaw/workspace/memory/`.
|
||||
#[tauri::command]
|
||||
pub async fn open_workspace_directory(handle: AppHandle, subdir: String) -> Result<bool, String> {
|
||||
let dir = match subdir.as_str() {
|
||||
"memory" => get_openclaw_dir().join("workspace").join("memory"),
|
||||
_ => get_openclaw_dir().join("workspace"),
|
||||
};
|
||||
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
|
||||
handle
|
||||
.opener()
|
||||
.open_path(dir.to_string_lossy().to_string(), None::<String>)
|
||||
.map_err(|e| format!("Failed to open directory: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
||||
|
||||
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
|
||||
use super::{lock_conn, Database};
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
use chrono::Utc;
|
||||
use chrono::{Local, Utc};
|
||||
use rusqlite::backup::Backup;
|
||||
use rusqlite::types::ValueRef;
|
||||
use rusqlite::Connection;
|
||||
@@ -15,6 +15,15 @@ use tempfile::NamedTempFile;
|
||||
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
/// A database backup entry for the UI
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupEntry {
|
||||
pub filename: String,
|
||||
pub size_bytes: u64,
|
||||
pub created_at: String, // ISO 8601
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||
@@ -120,8 +129,47 @@ impl Database {
|
||||
))
|
||||
}
|
||||
|
||||
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
||||
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||||
let interval_hours = crate::settings::effective_backup_interval_hours();
|
||||
if interval_hours == 0 {
|
||||
return Ok(()); // Auto-backup disabled
|
||||
}
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
}
|
||||
};
|
||||
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||||
pub(crate) fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
|
||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
@@ -134,7 +182,7 @@ impl Database {
|
||||
|
||||
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||||
|
||||
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
let base_id = format!("db_backup_{}", Local::now().format("%Y%m%d_%H%M%S"));
|
||||
let mut backup_id = base_id.clone();
|
||||
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||
let mut counter = 1;
|
||||
@@ -161,6 +209,7 @@ impl Database {
|
||||
|
||||
/// 清理旧的数据库备份,保留最新的 N 个
|
||||
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
||||
let retain = crate::settings::effective_backup_retain_count();
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(iter) => iter
|
||||
.filter_map(|entry| entry.ok())
|
||||
@@ -175,11 +224,11 @@ impl Database {
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
if entries.len() <= DB_BACKUP_RETAIN {
|
||||
if entries.len() <= retain {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
|
||||
let remove_count = entries.len().saturating_sub(retain);
|
||||
let mut sorted = entries;
|
||||
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
||||
|
||||
@@ -333,4 +382,178 @@ impl Database {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all database backup files, sorted by creation time (newest first)
|
||||
pub fn list_backups() -> Result<Vec<BackupEntry>, AppError> {
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut entries: Vec<BackupEntry> = fs::read_dir(&backup_dir)
|
||||
.map_err(|e| AppError::io(&backup_dir, e))?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| {
|
||||
let metadata = e.metadata().ok()?;
|
||||
let filename = e.file_name().to_string_lossy().to_string();
|
||||
let size_bytes = metadata.len();
|
||||
let created_at = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.map(|t| {
|
||||
let dt: chrono::DateTime<Utc> = t.into();
|
||||
dt.to_rfc3339()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(BackupEntry {
|
||||
filename,
|
||||
size_bytes,
|
||||
created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by created_at descending (newest first)
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Restore database from a backup file. Returns the safety backup ID.
|
||||
pub fn restore_from_backup(&self, filename: &str) -> Result<String, AppError> {
|
||||
// Security: validate filename to prevent path traversal
|
||||
if filename.contains("..")
|
||||
|| filename.contains('/')
|
||||
|| filename.contains('\\')
|
||||
|| !filename.ends_with(".db")
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid backup filename".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
let backup_path = backup_dir.join(filename);
|
||||
|
||||
if !backup_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Backup file not found: {filename}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Step 1: Create safety backup of current database
|
||||
let safety_backup = self.backup_database_file()?;
|
||||
let safety_id = safety_backup
|
||||
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Step 2: Open the backup file and restore it to the main database
|
||||
let source_conn =
|
||||
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
{
|
||||
let mut main_conn = lock_conn!(self.conn);
|
||||
let backup = Backup::new(&source_conn, &mut main_conn)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
backup
|
||||
.step(-1)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
// Step 3: Run schema migrations (backup may be from an older version)
|
||||
self.create_tables()?;
|
||||
self.apply_schema_migrations()?;
|
||||
self.ensure_model_pricing_seeded()?;
|
||||
|
||||
log::info!("Database restored from backup: {filename}, safety backup: {safety_id}");
|
||||
Ok(safety_id)
|
||||
}
|
||||
|
||||
/// Rename a backup file. Returns the new filename.
|
||||
pub fn rename_backup(old_filename: &str, new_name: &str) -> Result<String, AppError> {
|
||||
// Validate old filename (path traversal + .db suffix)
|
||||
if old_filename.contains("..")
|
||||
|| old_filename.contains('/')
|
||||
|| old_filename.contains('\\')
|
||||
|| !old_filename.ends_with(".db")
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid backup filename".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Clean new name
|
||||
let trimmed = new_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"New name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Length limit (without .db suffix)
|
||||
let name_part = trimmed.strip_suffix(".db").unwrap_or(trimmed);
|
||||
if name_part.len() > 100 {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Name too long (max 100 characters)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Prevent path traversal in new name
|
||||
if name_part.contains("..")
|
||||
|| name_part.contains('/')
|
||||
|| name_part.contains('\\')
|
||||
|| name_part.contains('\0')
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid characters in new name".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_filename = format!("{name_part}.db");
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
let old_path = backup_dir.join(old_filename);
|
||||
let new_path = backup_dir.join(&new_filename);
|
||||
|
||||
if !old_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Backup file not found: {old_filename}"
|
||||
)));
|
||||
}
|
||||
|
||||
if new_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"A backup named '{new_filename}' already exists"
|
||||
)));
|
||||
}
|
||||
|
||||
fs::rename(&old_path, &new_path).map_err(|e| AppError::io(&old_path, e))?;
|
||||
log::info!("Renamed backup: {old_filename} -> {new_filename}");
|
||||
Ok(new_filename)
|
||||
}
|
||||
|
||||
/// Delete a backup file permanently.
|
||||
pub fn delete_backup(filename: &str) -> Result<(), AppError> {
|
||||
// Validate filename (path traversal + .db suffix)
|
||||
if filename.contains("..")
|
||||
|| filename.contains('/')
|
||||
|| filename.contains('\\')
|
||||
|| !filename.ends_with(".db")
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Invalid backup filename".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let backup_path = get_app_config_dir().join("backups").join(filename);
|
||||
if !backup_path.exists() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"Backup file not found: {filename}"
|
||||
)));
|
||||
}
|
||||
|
||||
fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?;
|
||||
log::info!("Deleted backup: {filename}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod omo;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod proxy;
|
||||
@@ -16,4 +15,3 @@ pub mod universal_providers;
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
pub use failover::FailoverQueueItem;
|
||||
pub use omo::OmoGlobalConfig;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OmoGlobalConfig {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub schema_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sisyphus_agent: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub disabled_agents: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub disabled_mcps: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub disabled_hooks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub disabled_skills: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lsp: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experimental: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background_task: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub browser_automation_engine: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub claude_code: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub other_fields: Option<serde_json::Value>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Default for OmoGlobalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "global".to_string(),
|
||||
schema_url: None,
|
||||
sisyphus_agent: None,
|
||||
disabled_agents: vec![],
|
||||
disabled_mcps: vec![],
|
||||
disabled_hooks: vec![],
|
||||
disabled_skills: vec![],
|
||||
lsp: None,
|
||||
experimental: None,
|
||||
background_task: None,
|
||||
browser_automation_engine: None,
|
||||
claude_code: None,
|
||||
other_fields: None,
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn get_omo_global_config(&self) -> Result<OmoGlobalConfig, AppError> {
|
||||
let json_str = self.get_setting("common_config_omo")?;
|
||||
match json_str {
|
||||
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&s)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))),
|
||||
None => Ok(OmoGlobalConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_omo_global_config(&self, 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("common_config_omo", &json_str)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -364,25 +364,39 @@ impl Database {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
category: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
tx.execute(
|
||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
|
||||
params![app_type],
|
||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
|
||||
params![app_type, category],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
// OMO ↔ OMO Slim mutually exclusive: deactivate the opposite category
|
||||
let opposite = match category {
|
||||
"omo" => Some("omo-slim"),
|
||||
"omo-slim" => Some("omo"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(opp) = opposite {
|
||||
tx.execute(
|
||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
|
||||
params![app_type, opp],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
let updated = tx
|
||||
.execute(
|
||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||
params![provider_id, app_type],
|
||||
)
|
||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||
params![provider_id, app_type, category],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if updated != 1 {
|
||||
return Err(AppError::Database(format!(
|
||||
"Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'"
|
||||
"Failed to set {category} provider current: provider '{provider_id}' not found in app '{app_type}'"
|
||||
)));
|
||||
}
|
||||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -393,12 +407,13 @@ impl Database {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
category: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
match conn.query_row(
|
||||
"SELECT is_current FROM providers
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||
params![provider_id, app_type],
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||
params![provider_id, app_type, category],
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(is_current) => Ok(is_current),
|
||||
@@ -411,25 +426,30 @@ impl Database {
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
category: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE providers SET is_current = 0
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
||||
params![provider_id, app_type],
|
||||
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||
params![provider_id, app_type, category],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_current_omo_provider(&self, app_type: &str) -> Result<Option<Provider>, AppError> {
|
||||
pub fn get_current_omo_provider(
|
||||
&self,
|
||||
app_type: &str,
|
||||
category: &str,
|
||||
) -> Result<Option<Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
|
||||
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
|
||||
FROM providers
|
||||
WHERE app_type = ?1 AND category = 'omo' AND is_current = 1
|
||||
WHERE app_type = ?1 AND category = ?2 AND is_current = 1
|
||||
LIMIT 1",
|
||||
params![app_type],
|
||||
params![app_type, category],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
@@ -444,7 +464,7 @@ impl Database {
|
||||
},
|
||||
);
|
||||
|
||||
let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) =
|
||||
let (id, name, settings_config_str, _row_category, created_at, sort_index, notes, meta_str) =
|
||||
match row_data {
|
||||
Ok(v) => v,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
@@ -453,7 +473,7 @@ impl Database {
|
||||
|
||||
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
|
||||
AppError::Database(format!(
|
||||
"Failed to parse OMO provider settings_config (provider_id={id}): {e}"
|
||||
"Failed to parse {category} provider settings_config (provider_id={id}): {e}"
|
||||
))
|
||||
})?;
|
||||
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
|
||||
@@ -461,7 +481,7 @@ impl Database {
|
||||
} else {
|
||||
serde_json::from_str(&meta_str).map_err(|e| {
|
||||
AppError::Database(format!(
|
||||
"Failed to parse OMO provider meta (provider_id={id}): {e}"
|
||||
"Failed to parse {category} provider meta (provider_id={id}): {e}"
|
||||
))
|
||||
})?
|
||||
};
|
||||
@@ -471,7 +491,7 @@ impl Database {
|
||||
name,
|
||||
settings_config,
|
||||
website_url: None,
|
||||
category,
|
||||
category: Some(category.to_string()),
|
||||
created_at,
|
||||
sort_index,
|
||||
notes,
|
||||
|
||||
@@ -38,7 +38,7 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Config Snippets 辅助方法 ---
|
||||
// --- 通用配置片段 (Common Config Snippet) ---
|
||||
|
||||
/// 获取通用配置片段
|
||||
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! └── settings.rs
|
||||
//! ```
|
||||
|
||||
mod backup;
|
||||
pub(crate) mod backup;
|
||||
mod dao;
|
||||
mod migration;
|
||||
mod schema;
|
||||
@@ -33,7 +33,6 @@ mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub use dao::FailoverQueueItem;
|
||||
pub use dao::OmoGlobalConfig;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
@@ -43,9 +42,6 @@ use std::sync::Mutex;
|
||||
|
||||
// DAO 方法通过 impl Database 提供,无需额外导出
|
||||
|
||||
/// 数据库备份保留数量
|
||||
const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||
@@ -110,6 +106,22 @@ impl Database {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.create_tables()?;
|
||||
|
||||
// Pre-migration backup: only when upgrading from an existing database
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
let version = Self::get_user_version(&conn)?;
|
||||
drop(conn);
|
||||
if version > 0 && version < SCHEMA_VERSION {
|
||||
log::info!(
|
||||
"Creating pre-migration database backup (v{version} → v{SCHEMA_VERSION})"
|
||||
);
|
||||
if let Err(e) = db.backup_database_file() {
|
||||
log::warn!("Pre-migration backup failed, continuing migration: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.apply_schema_migrations()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
|
||||
+62
-67
@@ -448,63 +448,7 @@ pub fn run() {
|
||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||
}
|
||||
|
||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
||||
for app in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
] {
|
||||
match crate::services::provider::ProviderService::import_default_config(
|
||||
&app_state,
|
||||
app.clone(),
|
||||
) {
|
||||
Ok(true) => {
|
||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
||||
|
||||
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
|
||||
if app_state
|
||||
.db
|
||||
.get_config_snippet(app.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
{
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
|
||||
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
|
||||
} else {
|
||||
log::info!("✓ Extracted common config snippet for {}", app.as_str());
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
|
||||
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {} // 已有供应商,静默跳过
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"○ No default provider to import for {}: {}",
|
||||
app.as_str(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||
// 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 时,从本地文件导入)
|
||||
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
|
||||
{
|
||||
let has_omo = app_state
|
||||
.db
|
||||
@@ -512,7 +456,7 @@ pub fn run() {
|
||||
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
|
||||
.unwrap_or(false);
|
||||
if !has_omo {
|
||||
match crate::services::OmoService::import_from_local(&app_state) {
|
||||
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::STANDARD) {
|
||||
Ok(provider) => {
|
||||
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
|
||||
}
|
||||
@@ -526,15 +470,33 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 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");
|
||||
// 2.3 OMO Slim config import (when no omo-slim provider in DB, import from local)
|
||||
{
|
||||
let has_omo_slim = app_state
|
||||
.db
|
||||
.get_all_providers("opencode")
|
||||
.map(|providers| {
|
||||
providers
|
||||
.values()
|
||||
.any(|p| p.category.as_deref() == Some("omo-slim"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !has_omo_slim {
|
||||
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::SLIM) {
|
||||
Ok(provider) => {
|
||||
log::info!(
|
||||
"✓ Imported OMO Slim config from local as provider '{}'",
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
Err(AppError::OmoConfigNotFound) => {
|
||||
log::debug!("○ No OMO Slim config to import");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("✗ Failed to import OMO Slim config from local: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
|
||||
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {e}"),
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
@@ -784,6 +746,25 @@ pub fn run() {
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
|
||||
// Periodic backup check (on startup)
|
||||
if let Err(e) = state.db.periodic_backup_if_needed() {
|
||||
log::warn!("Periodic backup failed on startup: {e}");
|
||||
}
|
||||
|
||||
// Periodic backup timer: check every hour while the app is running
|
||||
let db_for_timer = state.db.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||
interval.tick().await; // skip immediate first tick (already checked above)
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
|
||||
log::warn!("Periodic backup timer failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||
@@ -912,6 +893,11 @@ pub fn run() {
|
||||
commands::save_file_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::open_zip_file_dialog,
|
||||
commands::create_db_backup,
|
||||
commands::list_db_backups,
|
||||
commands::restore_db_backup,
|
||||
commands::rename_db_backup,
|
||||
commands::delete_db_backup,
|
||||
commands::sync_current_providers_live,
|
||||
// Deep link import
|
||||
commands::parse_deeplink,
|
||||
@@ -1033,11 +1019,20 @@ 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::disable_current_omo_slim,
|
||||
// Workspace files (OpenClaw)
|
||||
commands::read_workspace_file,
|
||||
commands::write_workspace_file,
|
||||
// Daily memory files (OpenClaw workspace)
|
||||
commands::list_daily_memory_files,
|
||||
commands::read_daily_memory_file,
|
||||
commands::write_daily_memory_file,
|
||||
commands::delete_daily_memory_file,
|
||||
commands::search_daily_memory_files,
|
||||
commands::open_workspace_directory,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
@@ -145,14 +145,25 @@ pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
||||
|
||||
match plugins {
|
||||
Some(arr) => {
|
||||
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||
if plugin_name.starts_with("oh-my-opencode")
|
||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||
{
|
||||
// Adding standard OMO -> remove all Slim variants
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
} else if plugin_name.starts_with("oh-my-opencode-slim") {
|
||||
// Adding Slim -> remove all standard OMO variants (but keep slim)
|
||||
arr.retain(|v| {
|
||||
v.as_str()
|
||||
.map(|s| {
|
||||
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
|
||||
})
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
|
||||
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||
|
||||
@@ -18,7 +18,7 @@ pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
pub use omo::OmoService;
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use provider::{ProviderService, ProviderSortUpdate, SwitchResult};
|
||||
pub use proxy::ProxyService;
|
||||
#[allow(unused_imports)]
|
||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
|
||||
+157
-210
@@ -1,5 +1,4 @@
|
||||
use crate::config::write_json_file;
|
||||
use crate::database::OmoGlobalConfig;
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config::get_opencode_dir;
|
||||
use crate::store::AppState;
|
||||
@@ -13,22 +12,60 @@ pub struct OmoLocalFileData {
|
||||
pub agents: Option<Value>,
|
||||
pub categories: Option<Value>,
|
||||
pub other_fields: Option<Value>,
|
||||
pub global: OmoGlobalConfig,
|
||||
pub file_path: String,
|
||||
pub last_modified: Option<String>,
|
||||
}
|
||||
|
||||
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
|
||||
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
|
||||
|
||||
// ── Variant descriptor ─────────────────────────────────────────
|
||||
|
||||
pub struct OmoVariant {
|
||||
pub filename: &'static str,
|
||||
pub category: &'static str,
|
||||
pub provider_prefix: &'static str,
|
||||
pub plugin_name: &'static str,
|
||||
pub plugin_prefix: &'static str,
|
||||
pub has_categories: bool,
|
||||
pub label: &'static str,
|
||||
pub import_label: &'static str,
|
||||
}
|
||||
|
||||
pub const STANDARD: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode.jsonc",
|
||||
category: "omo",
|
||||
provider_prefix: "omo-",
|
||||
plugin_name: "oh-my-opencode@latest",
|
||||
plugin_prefix: "oh-my-opencode",
|
||||
has_categories: true,
|
||||
label: "OMO",
|
||||
import_label: "Imported",
|
||||
};
|
||||
|
||||
pub const SLIM: OmoVariant = OmoVariant {
|
||||
filename: "oh-my-opencode-slim.jsonc",
|
||||
category: "omo-slim",
|
||||
provider_prefix: "omo-slim-",
|
||||
plugin_name: "oh-my-opencode-slim@latest",
|
||||
plugin_prefix: "oh-my-opencode-slim",
|
||||
has_categories: false,
|
||||
label: "OMO Slim",
|
||||
import_label: "Imported Slim",
|
||||
};
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────
|
||||
|
||||
pub struct OmoService;
|
||||
|
||||
impl OmoService {
|
||||
fn config_path() -> PathBuf {
|
||||
get_opencode_dir().join("oh-my-opencode.jsonc")
|
||||
// ── Path helpers ────────────────────────────────────────
|
||||
|
||||
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||
get_opencode_dir().join(v.filename)
|
||||
}
|
||||
|
||||
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
|
||||
let config_path = Self::config_path();
|
||||
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
return Ok(config_path);
|
||||
}
|
||||
@@ -52,72 +89,22 @@ impl OmoService {
|
||||
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
||||
}
|
||||
|
||||
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
|
||||
const KNOWN_KEYS: [&str; 13] = [
|
||||
"$schema",
|
||||
"agents",
|
||||
"categories",
|
||||
"sisyphus_agent",
|
||||
"disabled_agents",
|
||||
"disabled_mcps",
|
||||
"disabled_hooks",
|
||||
"disabled_skills",
|
||||
"lsp",
|
||||
"experimental",
|
||||
"background_task",
|
||||
"browser_automation_engine",
|
||||
"claude_code",
|
||||
];
|
||||
// ── Field extraction ───────────────────────────────────
|
||||
|
||||
fn extract_other_fields_with_keys(
|
||||
obj: &Map<String, Value>,
|
||||
known: &[&str],
|
||||
) -> Map<String, Value> {
|
||||
let mut other = Map::new();
|
||||
for (k, v) in obj {
|
||||
if !KNOWN_KEYS.contains(&k.as_str()) {
|
||||
if !known.contains(&k.as_str()) {
|
||||
other.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
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>) {
|
||||
if let Some(v) = value {
|
||||
@@ -125,15 +112,6 @@ impl OmoService {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_string_array(result: &mut Map<String, Value>, key: &str, values: &[String]) {
|
||||
if !values.is_empty() {
|
||||
result.insert(
|
||||
key.to_string(),
|
||||
serde_json::to_value(values).unwrap_or(Value::Array(vec![])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
|
||||
if let Some(Value::Object(map)) = value {
|
||||
for (k, v) in map {
|
||||
@@ -142,115 +120,84 @@ impl OmoService {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_config_file() -> Result<(), AppError> {
|
||||
let config_path = Self::config_path();
|
||||
// ── Public API (variant-parameterized) ─────────────────
|
||||
|
||||
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||
let config_path = Self::config_path(v);
|
||||
if config_path.exists() {
|
||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
log::info!("OMO config file deleted: {config_path:?}");
|
||||
log::info!("{} config file deleted: {config_path:?}", v.label);
|
||||
}
|
||||
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?;
|
||||
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
|
||||
let global = state.db.get_omo_global_config()?;
|
||||
let current_omo = state.db.get_current_omo_provider("opencode")?;
|
||||
|
||||
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||
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 = p.settings_config.get("categories").cloned();
|
||||
let categories = if v.has_categories {
|
||||
p.settings_config.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let other_fields = p.settings_config.get("otherFields").cloned();
|
||||
let use_common_config = p
|
||||
.settings_config
|
||||
.get("useCommonConfig")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
(agents, categories, other_fields, use_common_config)
|
||||
(agents, categories, other_fields)
|
||||
});
|
||||
|
||||
let merged = Self::merge_config(&global, profile_data.as_ref());
|
||||
let config_path = Self::config_path();
|
||||
let merged = Self::build_config(v, profile_data.as_ref());
|
||||
let config_path = Self::config_path(v);
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
write_json_file(&config_path, &merged)?;
|
||||
|
||||
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
|
||||
|
||||
log::info!("OMO config written to {config_path:?}");
|
||||
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||
log::info!("{} config written to {config_path:?}", v.label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value {
|
||||
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
|
||||
let mut result = Map::new();
|
||||
let use_common_config = profile_data.map(|(_, _, _, v)| *v).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);
|
||||
Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills);
|
||||
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
|
||||
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
|
||||
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_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);
|
||||
Self::insert_opt_value(&mut result, "categories", categories);
|
||||
if let Some((agents, categories, other_fields)) = profile_data {
|
||||
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
||||
Self::insert_opt_value(&mut result, "agents", agents);
|
||||
if v.has_categories {
|
||||
Self::insert_opt_value(&mut result, "categories", categories);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(result)
|
||||
}
|
||||
|
||||
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
|
||||
let actual_path = Self::resolve_local_config_path()?;
|
||||
Self::import_from_path(state, &actual_path)
|
||||
}
|
||||
|
||||
fn import_from_path(
|
||||
pub fn import_from_local(
|
||||
state: &AppState,
|
||||
path: &std::path::Path,
|
||||
v: &OmoVariant,
|
||||
) -> Result<crate::provider::Provider, AppError> {
|
||||
let obj = Self::read_jsonc_object(path)?;
|
||||
let actual_path = Self::resolve_local_config_path(v)?;
|
||||
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||
|
||||
let mut settings = Map::new();
|
||||
if let Some(agents) = obj.get("agents") {
|
||||
settings.insert("agents".to_string(), agents.clone());
|
||||
}
|
||||
if let Some(categories) = obj.get("categories") {
|
||||
settings.insert("categories".to_string(), categories.clone());
|
||||
if v.has_categories {
|
||||
if let Some(categories) = obj.get("categories") {
|
||||
settings.insert("categories".to_string(), categories.clone());
|
||||
}
|
||||
}
|
||||
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
|
||||
|
||||
let other = Self::extract_other_fields(&obj);
|
||||
let other = Self::extract_other_fields_with_keys(&obj, &["agents", "categories"]);
|
||||
if !other.is_empty() {
|
||||
settings.insert("otherFields".to_string(), Value::Object(other));
|
||||
}
|
||||
|
||||
let mut global = state.db.get_omo_global_config()?;
|
||||
Self::merge_global_from_obj(&obj, &mut global);
|
||||
global.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
state.db.save_omo_global_config(&global)?;
|
||||
|
||||
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
|
||||
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
|
||||
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
|
||||
let name = format!(
|
||||
"{} {}",
|
||||
v.import_label,
|
||||
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||
);
|
||||
let settings_config =
|
||||
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
||||
|
||||
@@ -259,7 +206,7 @@ impl OmoService {
|
||||
name,
|
||||
settings_config,
|
||||
website_url: None,
|
||||
category: Some("omo".to_string()),
|
||||
category: Some(v.category.to_string()),
|
||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
@@ -272,13 +219,13 @@ impl OmoService {
|
||||
state.db.save_provider("opencode", &provider)?;
|
||||
state
|
||||
.db
|
||||
.set_omo_provider_current("opencode", &provider.id)?;
|
||||
Self::write_config_to_file(state)?;
|
||||
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||
Self::write_config_to_file(state, v)?;
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
|
||||
let actual_path = Self::resolve_local_config_path()?;
|
||||
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
|
||||
let actual_path = Self::resolve_local_config_path(v)?;
|
||||
let metadata = std::fs::metadata(&actual_path).ok();
|
||||
let last_modified = metadata
|
||||
.and_then(|m| m.modified().ok())
|
||||
@@ -286,37 +233,38 @@ impl OmoService {
|
||||
|
||||
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||
|
||||
Ok(Self::build_local_file_data_from_obj(
|
||||
Ok(Self::build_local_file_data(
|
||||
v,
|
||||
&obj,
|
||||
actual_path.to_string_lossy().to_string(),
|
||||
last_modified,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_local_file_data_from_obj(
|
||||
fn build_local_file_data(
|
||||
v: &OmoVariant,
|
||||
obj: &Map<String, Value>,
|
||||
file_path: String,
|
||||
last_modified: Option<String>,
|
||||
) -> OmoLocalFileData {
|
||||
let agents = obj.get("agents").cloned();
|
||||
let categories = obj.get("categories").cloned();
|
||||
let categories = if v.has_categories {
|
||||
obj.get("categories").cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let other = Self::extract_other_fields(obj);
|
||||
let other = Self::extract_other_fields_with_keys(obj, &["agents", "categories"]);
|
||||
let other_fields = if other.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(other))
|
||||
};
|
||||
|
||||
let mut global = OmoGlobalConfig::default();
|
||||
Self::merge_global_from_obj(obj, &mut global);
|
||||
global.other_fields = other_fields.clone();
|
||||
|
||||
OmoLocalFileData {
|
||||
agents,
|
||||
categories,
|
||||
other_fields,
|
||||
global,
|
||||
file_path,
|
||||
last_modified,
|
||||
}
|
||||
@@ -399,26 +347,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_config_empty() {
|
||||
let global = OmoGlobalConfig::default();
|
||||
let merged = OmoService::merge_config(&global, None);
|
||||
fn test_build_config_empty() {
|
||||
let merged = OmoService::build_config(&STANDARD, None);
|
||||
assert!(merged.is_object());
|
||||
assert!(merged.as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_config_with_profile() {
|
||||
let global = OmoGlobalConfig {
|
||||
schema_url: Some("https://example.com/schema.json".to_string()),
|
||||
disabled_agents: vec!["explore".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
fn test_build_config_with_profile() {
|
||||
let agents = Some(serde_json::json!({
|
||||
"sisyphus": { "model": "claude-opus-4-5" }
|
||||
}));
|
||||
let categories = None;
|
||||
let other_fields = None;
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||
let other_fields = Some(serde_json::json!({
|
||||
"$schema": "https://example.com/schema.json",
|
||||
"disabled_agents": ["explore"]
|
||||
}));
|
||||
let profile_data = (agents, categories, other_fields);
|
||||
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
||||
@@ -428,28 +374,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_config_without_common_config() {
|
||||
let global = OmoGlobalConfig {
|
||||
schema_url: Some("https://example.com/schema.json".to_string()),
|
||||
disabled_agents: vec!["explore".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let agents = Some(serde_json::json!({
|
||||
"sisyphus": { "model": "claude-opus-4-5" }
|
||||
}));
|
||||
let categories = None;
|
||||
let other_fields = None;
|
||||
let profile_data = (agents, categories, other_fields, false);
|
||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert!(!obj.contains_key("$schema"));
|
||||
assert!(!obj.contains_key("disabled_agents"));
|
||||
assert!(obj.contains_key("agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() {
|
||||
fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() {
|
||||
let obj = serde_json::json!({
|
||||
"$schema": "https://example.com/schema.json",
|
||||
"disabled_agents": ["oracle"],
|
||||
@@ -465,43 +390,65 @@ mod tests {
|
||||
});
|
||||
let obj_map = obj.as_object().unwrap().clone();
|
||||
|
||||
let data = OmoService::build_local_file_data_from_obj(
|
||||
let data = OmoService::build_local_file_data(
|
||||
&STANDARD,
|
||||
&obj_map,
|
||||
"/tmp/oh-my-opencode.jsonc".to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
// All non-agents/categories fields should be in other_fields
|
||||
let other = data.other_fields.unwrap();
|
||||
let other_obj = other.as_object().unwrap();
|
||||
assert_eq!(
|
||||
data.global.schema_url.as_deref(),
|
||||
Some("https://example.com/schema.json")
|
||||
other_obj.get("$schema").unwrap(),
|
||||
"https://example.com/schema.json"
|
||||
);
|
||||
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
|
||||
|
||||
assert_eq!(
|
||||
data.other_fields,
|
||||
Some(serde_json::json!({
|
||||
"custom_top_level": { "enabled": true }
|
||||
}))
|
||||
other_obj.get("disabled_agents").unwrap(),
|
||||
&serde_json::json!(["oracle"])
|
||||
);
|
||||
assert_eq!(data.global.other_fields, data.other_fields);
|
||||
assert_eq!(
|
||||
other_obj.get("custom_top_level").unwrap(),
|
||||
&serde_json::json!({"enabled": true})
|
||||
);
|
||||
// agents and categories should NOT be in other_fields
|
||||
assert!(!other_obj.contains_key("agents"));
|
||||
assert!(!other_obj.contains_key("categories"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_config_ignores_non_object_other_fields() {
|
||||
let global = OmoGlobalConfig {
|
||||
other_fields: Some(serde_json::json!(["global_non_object"])),
|
||||
..Default::default()
|
||||
};
|
||||
fn test_build_config_ignores_non_object_other_fields() {
|
||||
let agents = None;
|
||||
let categories = None;
|
||||
let other_fields = Some(serde_json::json!("profile_non_object"));
|
||||
let profile_data = (agents, categories, other_fields, true);
|
||||
let profile_data = (agents, categories, other_fields);
|
||||
|
||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
||||
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
assert!(!obj.contains_key("0"));
|
||||
assert!(!obj.contains_key("global_non_object"));
|
||||
assert!(!obj.contains_key("profile_non_object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_config_slim_excludes_categories() {
|
||||
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
|
||||
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
|
||||
let other_fields = Some(serde_json::json!({
|
||||
"$schema": "https://slim.schema",
|
||||
"disabled_agents": ["oracle"]
|
||||
}));
|
||||
let profile_data = (agents, categories, other_fields);
|
||||
|
||||
let merged = OmoService::build_config(&SLIM, Some(&profile_data));
|
||||
let obj = merged.as_object().unwrap();
|
||||
|
||||
// Slim should NOT include categories
|
||||
assert!(!obj.contains_key("categories"));
|
||||
|
||||
// Slim SHOULD include these
|
||||
assert_eq!(obj["$schema"], "https://slim.schema");
|
||||
assert!(obj.contains_key("agents"));
|
||||
assert!(obj.contains_key("disabled_agents"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,13 @@ use usage::validate_usage_script;
|
||||
/// Provider business logic service
|
||||
pub struct ProviderService;
|
||||
|
||||
/// Result of a provider switch operation, including any non-fatal warnings
|
||||
#[derive(Debug, serde::Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SwitchResult {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -166,11 +173,11 @@ impl ProviderService {
|
||||
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||
if app_type.is_additive_mode() {
|
||||
// OMO providers use exclusive mode and write to dedicated config file.
|
||||
// OMO / OMO Slim providers use exclusive mode and write to dedicated config file.
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
&& matches!(provider.category.as_deref(), Some("omo") | Some("omo-slim"))
|
||||
{
|
||||
// Do not auto-enable newly added OMO providers.
|
||||
// Do not auto-enable newly added OMO / OMO Slim providers.
|
||||
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -207,14 +214,33 @@ impl ProviderService {
|
||||
|
||||
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo")
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
|
||||
{
|
||||
let is_omo_current = state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
||||
let is_omo_current =
|
||||
state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), &provider.id, "omo")?;
|
||||
if is_omo_current {
|
||||
crate::services::OmoService::write_config_to_file(state)?;
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& provider.category.as_deref() == Some("omo-slim")
|
||||
{
|
||||
let is_current = state.db.is_omo_provider_current(
|
||||
app_type.as_str(),
|
||||
&provider.id,
|
||||
"omo-slim",
|
||||
)?;
|
||||
if is_current {
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -264,31 +290,37 @@ impl ProviderService {
|
||||
// Additive mode apps - no current provider concept
|
||||
if app_type.is_additive_mode() {
|
||||
if matches!(app_type, AppType::OpenCode) {
|
||||
let is_omo = state
|
||||
let provider_category = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
.and_then(|p| p.category);
|
||||
|
||||
if is_omo {
|
||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
||||
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(),
|
||||
));
|
||||
}
|
||||
if provider_category.as_deref() == Some("omo") {
|
||||
let was_current =
|
||||
state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if provider_category.as_deref() == Some("omo-slim") {
|
||||
let was_current =
|
||||
state
|
||||
.db
|
||||
.is_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||
|
||||
state.db.delete_provider(app_type.as_str(), id)?;
|
||||
if was_current {
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -329,21 +361,46 @@ impl ProviderService {
|
||||
) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::OpenCode => {
|
||||
let is_omo = state
|
||||
let provider_category = state
|
||||
.db
|
||||
.get_provider_by_id(id, app_type.as_str())?
|
||||
.and_then(|p| p.category)
|
||||
.as_deref()
|
||||
== Some("omo");
|
||||
.and_then(|p| p.category);
|
||||
|
||||
if is_omo {
|
||||
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
|
||||
let still_has_current =
|
||||
state.db.get_current_omo_provider("opencode")?.is_some();
|
||||
if provider_category.as_deref() == Some("omo") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||
let still_has_current = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo")?
|
||||
.is_some();
|
||||
if still_has_current {
|
||||
crate::services::OmoService::write_config_to_file(state)?;
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
} else {
|
||||
crate::services::OmoService::delete_config_file()?;
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::STANDARD,
|
||||
)?;
|
||||
}
|
||||
} else if provider_category.as_deref() == Some("omo-slim") {
|
||||
state
|
||||
.db
|
||||
.clear_omo_provider_current(app_type.as_str(), id, "omo-slim")?;
|
||||
let still_has_current = state
|
||||
.db
|
||||
.get_current_omo_provider("opencode", "omo-slim")?
|
||||
.is_some();
|
||||
if still_has_current {
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
state,
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
} else {
|
||||
crate::services::OmoService::delete_config_file(
|
||||
&crate::services::omo::SLIM,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
remove_opencode_provider_from_live(id)?;
|
||||
@@ -374,7 +431,7 @@ impl ProviderService {
|
||||
/// c. Update database is_current (as default for new devices)
|
||||
/// d. Write target provider config to live files
|
||||
/// e. Sync MCP configuration
|
||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
|
||||
// Check if provider exists
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let _provider = providers
|
||||
@@ -386,6 +443,13 @@ impl ProviderService {
|
||||
return Self::switch_normal(state, app_type, id, &providers);
|
||||
}
|
||||
|
||||
// OMO Slim providers are switched through their own exclusive path.
|
||||
if matches!(app_type, AppType::OpenCode)
|
||||
&& _provider.category.as_deref() == Some("omo-slim")
|
||||
{
|
||||
return Self::switch_normal(state, app_type, id, &providers);
|
||||
}
|
||||
|
||||
// Check if proxy takeover mode is active AND proxy server is actually running
|
||||
// Both conditions must be true to use hot-switch mode
|
||||
// Use blocking wait since this is a sync function
|
||||
@@ -439,7 +503,7 @@ impl ProviderService {
|
||||
|
||||
// Note: No Live config write, no MCP sync
|
||||
// The proxy server will route requests to the new provider via is_current
|
||||
return Ok(());
|
||||
return Ok(SwitchResult::default());
|
||||
}
|
||||
|
||||
// Normal mode: full switch with Live config write
|
||||
@@ -452,17 +516,38 @@ impl ProviderService {
|
||||
app_type: AppType,
|
||||
id: &str,
|
||||
providers: &indexmap::IndexMap<String, Provider>,
|
||||
) -> Result<(), AppError> {
|
||||
) -> Result<SwitchResult, AppError> {
|
||||
let provider = providers
|
||||
.get(id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
|
||||
state.db.set_omo_provider_current(app_type.as_str(), id)?;
|
||||
crate::services::OmoService::write_config_to_file(state)?;
|
||||
return Ok(());
|
||||
state
|
||||
.db
|
||||
.set_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||
crate::services::OmoService::write_config_to_file(
|
||||
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());
|
||||
}
|
||||
|
||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim")
|
||||
{
|
||||
state
|
||||
.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());
|
||||
}
|
||||
|
||||
let mut result = SwitchResult::default();
|
||||
|
||||
// Backfill: Backfill current live config to current provider
|
||||
// Use effective current provider (validated existence) to ensure backfill targets valid provider
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||
@@ -476,8 +561,14 @@ impl ProviderService {
|
||||
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;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
if let Err(e) =
|
||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||
{
|
||||
log::warn!("Backfill failed: {e}");
|
||||
result
|
||||
.warnings
|
||||
.push(format!("backfill_failed:{current_id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,7 +590,7 @@ impl ProviderService {
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(())
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration (re-export)
|
||||
|
||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
use providers::{claude, codex};
|
||||
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -37,9 +37,27 @@ pub struct SessionMessage {
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let (r1, r2, r3, r4, r5) = std::thread::scope(|s| {
|
||||
let h1 = s.spawn(codex::scan_sessions);
|
||||
let h2 = s.spawn(claude::scan_sessions);
|
||||
let h3 = s.spawn(opencode::scan_sessions);
|
||||
let h4 = s.spawn(openclaw::scan_sessions);
|
||||
let h5 = s.spawn(gemini::scan_sessions);
|
||||
(
|
||||
h1.join().unwrap_or_default(),
|
||||
h2.join().unwrap_or_default(),
|
||||
h3.join().unwrap_or_default(),
|
||||
h4.join().unwrap_or_default(),
|
||||
h5.join().unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
sessions.extend(codex::scan_sessions());
|
||||
sessions.extend(claude::scan_sessions());
|
||||
sessions.extend(r1);
|
||||
sessions.extend(r2);
|
||||
sessions.extend(r3);
|
||||
sessions.extend(r4);
|
||||
sessions.extend(r5);
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
||||
@@ -55,6 +73,9 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
|
||||
match provider_id {
|
||||
"codex" => codex::load_messages(path),
|
||||
"claude" => claude::load_messages(path),
|
||||
"opencode" => opencode::load_messages(path),
|
||||
"openclaw" => openclaw::load_messages(path),
|
||||
"gemini" => gemini::load_messages(path),
|
||||
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ use serde_json::Value;
|
||||
use crate::config::get_claude_config_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "claude";
|
||||
|
||||
@@ -73,60 +75,61 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
// Extract metadata from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if session_id.is_none() {
|
||||
session_id = value
|
||||
.get("sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if project_dir.is_none() {
|
||||
project_dir = value
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
if created_at.is_none() {
|
||||
created_at = Some(ts);
|
||||
}
|
||||
last_active_at = Some(ts);
|
||||
if created_at.is_none() {
|
||||
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
}
|
||||
}
|
||||
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
// Extract last_active_at and summary from tail lines (reverse order)
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(message) => message,
|
||||
None => continue,
|
||||
for line in tail.iter().rev() {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
if last_active_at.is_none() {
|
||||
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
}
|
||||
if summary.is_none() {
|
||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(message) = value.get("message") {
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
summary = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_active_at.is_some() && summary.is_some() {
|
||||
break;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
@@ -8,10 +9,17 @@ use serde_json::Value;
|
||||
use crate::codex_config::get_codex_config_dir;
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
use super::utils::{
|
||||
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "codex";
|
||||
|
||||
static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let root = get_codex_config_dir().join("sessions");
|
||||
let mut files = Vec::new();
|
||||
@@ -74,32 +82,21 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let file = File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut project_dir: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
// Extract metadata from head lines
|
||||
for line in &head {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
if created_at.is_none() {
|
||||
created_at = Some(ts);
|
||||
}
|
||||
last_active_at = Some(ts);
|
||||
if created_at.is_none() {
|
||||
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
}
|
||||
|
||||
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if session_id.is_none() {
|
||||
@@ -118,27 +115,34 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
created_at.get_or_insert(ts);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
||||
continue;
|
||||
}
|
||||
// Extract last_active_at and summary from tail lines (reverse order)
|
||||
let mut last_active_at: Option<i64> = None;
|
||||
let mut summary: Option<String> = None;
|
||||
|
||||
let payload = match value.get("payload") {
|
||||
Some(payload) => payload,
|
||||
None => continue,
|
||||
for line in tail.iter().rev() {
|
||||
let value: Value = match serde_json::from_str(line) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
if last_active_at.is_none() {
|
||||
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
}
|
||||
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
if summary.is_none() && value.get("type").and_then(Value::as_str) == Some("response_item") {
|
||||
if let Some(payload) = value.get("payload") {
|
||||
if payload.get("type").and_then(Value::as_str) == Some("message") {
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
summary = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_active_at.is_some() && summary.is_some() {
|
||||
break;
|
||||
}
|
||||
summary = Some(text);
|
||||
}
|
||||
|
||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||
@@ -166,10 +170,7 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
|
||||
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||
let file_name = path.file_name()?.to_string_lossy();
|
||||
let re =
|
||||
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||
.ok()?;
|
||||
re.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||
UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||
}
|
||||
|
||||
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{parse_timestamp_to_ms, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "gemini";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let gemini_dir = crate::gemini_config::get_gemini_dir();
|
||||
let tmp_dir = gemini_dir.join("tmp");
|
||||
if !tmp_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
// Iterate over project hash directories: tmp/<project_hash>/chats/session-*.json
|
||||
let project_dirs = match std::fs::read_dir(&tmp_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
for entry in project_dirs.flatten() {
|
||||
let chats_dir = entry.path().join("chats");
|
||||
if !chats_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chat_files = match std::fs::read_dir(&chats_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for file_entry in chat_files.flatten() {
|
||||
let path = file_entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let data = std::fs::read_to_string(path).map_err(|e| format!("Failed to read session: {e}"))?;
|
||||
let value: Value =
|
||||
serde_json::from_str(&data).map_err(|e| format!("Failed to parse session JSON: {e}"))?;
|
||||
|
||||
let messages = value
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "No messages array found".to_string())?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for msg in messages {
|
||||
let content = match msg.get("content").and_then(Value::as_str) {
|
||||
Some(c) if !c.trim().is_empty() => c.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let role = match msg.get("type").and_then(Value::as_str) {
|
||||
Some("gemini") => "assistant".to_string(),
|
||||
Some("user") => "user".to_string(),
|
||||
Some(other) => other.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let ts = msg.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
result.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
|
||||
let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string();
|
||||
|
||||
let created_at = value.get("startTime").and_then(parse_timestamp_to_ms);
|
||||
let last_active_at = value.get("lastUpdated").and_then(parse_timestamp_to_ms);
|
||||
|
||||
// Derive title from first user message
|
||||
let title = value
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|msgs| {
|
||||
msgs.iter()
|
||||
.find(|m| m.get("type").and_then(Value::as_str) == Some("user"))
|
||||
.and_then(|m| m.get("content").and_then(Value::as_str))
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| truncate_summary(s, 160))
|
||||
});
|
||||
|
||||
let source_path = path.to_string_lossy().to_string();
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title: title.clone(),
|
||||
summary: title,
|
||||
project_dir: None, // project hash is not reversible
|
||||
created_at,
|
||||
last_active_at: last_active_at.or(created_at),
|
||||
source_path: Some(source_path),
|
||||
resume_command: Some(format!("gemini --resume {session_id}")),
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
mod utils;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "openclaw";
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let agents_dir = get_openclaw_dir().join("agents");
|
||||
if !agents_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
// Traverse each agent directory
|
||||
let agent_entries = match std::fs::read_dir(&agents_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return sessions,
|
||||
};
|
||||
|
||||
for agent_entry in agent_entries.flatten() {
|
||||
let agent_path = agent_entry.path();
|
||||
if !agent_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sessions_dir = agent_path.join("sessions");
|
||||
if !sessions_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let session_entries = match std::fs::read_dir(&sessions_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in session_entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
// Skip sessions.json index file
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n == "sessions.json")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(meta) = parse_session(&path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
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 value.get("type").and_then(Value::as_str) != Some("message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = match value.get("message") {
|
||||
Some(msg) => msg,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let raw_role = message
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
|
||||
// Map OpenClaw roles to our standard roles
|
||||
let role = match raw_role {
|
||||
"toolResult" => "tool".to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
let content = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
|
||||
messages.push(SessionMessage { role, content, ts });
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||
|
||||
let mut session_id: Option<String> = None;
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut created_at: Option<i64> = None;
|
||||
let mut 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) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if created_at.is_none() {
|
||||
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||
}
|
||||
|
||||
let event_type = value.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
if event_type == "session" {
|
||||
if session_id.is_none() {
|
||||
session_id = value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if cwd.is_none() {
|
||||
cwd = value
|
||||
.get("cwd")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
||||
created_at.get_or_insert(ts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// OpenClaw summary is the first message content
|
||||
if event_type == "message" && summary.is_none() {
|
||||
if let Some(message) = value.get("message") {
|
||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
summary = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to filename as session ID
|
||||
let session_id = session_id.or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
let session_id = session_id?;
|
||||
|
||||
let title = cwd
|
||||
.as_deref()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let summary = summary.map(|text| truncate_summary(&text, 160));
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title,
|
||||
summary,
|
||||
project_dir: cwd,
|
||||
created_at,
|
||||
last_active_at,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
||||
|
||||
use super::utils::{parse_timestamp_to_ms, path_basename, truncate_summary};
|
||||
|
||||
const PROVIDER_ID: &str = "opencode";
|
||||
|
||||
/// Return the OpenCode data directory.
|
||||
///
|
||||
/// Respects `XDG_DATA_HOME` on all platforms; falls back to
|
||||
/// `~/.local/share/opencode/storage/`.
|
||||
fn get_opencode_data_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
return PathBuf::from(xdg).join("opencode").join("storage");
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".local/share/opencode/storage"))
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share/opencode/storage"))
|
||||
}
|
||||
|
||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||
let storage = get_opencode_data_dir();
|
||||
let session_dir = storage.join("session");
|
||||
if !session_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut json_files = Vec::new();
|
||||
collect_json_files(&session_dir, &mut json_files);
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for path in json_files {
|
||||
if let Some(meta) = parse_session(&storage, &path) {
|
||||
sessions.push(meta);
|
||||
}
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
|
||||
// `path` is the message directory: storage/message/{sessionID}/
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Message directory not found: {}", path.display()));
|
||||
}
|
||||
|
||||
let storage = path
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| "Cannot determine storage root from message path".to_string())?;
|
||||
|
||||
let mut msg_files = Vec::new();
|
||||
collect_json_files(path, &mut msg_files);
|
||||
|
||||
// Parse all messages and collect (created_ts, message_id, role, parts_text)
|
||||
let mut entries: Vec<(i64, String, String, String)> = Vec::new();
|
||||
|
||||
for msg_path in &msg_files {
|
||||
let data = match std::fs::read_to_string(msg_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let msg_id = match value.get("id").and_then(Value::as_str) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let role = value
|
||||
.get("role")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let created_ts = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(parse_timestamp_to_ms)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Collect text parts from storage/part/{messageID}/
|
||||
let part_dir = storage.join("part").join(&msg_id);
|
||||
let text = collect_parts_text(&part_dir);
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push((created_ts, msg_id, role, text));
|
||||
}
|
||||
|
||||
// Sort by created timestamp
|
||||
entries.sort_by_key(|(ts, _, _, _)| *ts);
|
||||
|
||||
let messages = entries
|
||||
.into_iter()
|
||||
.map(|(ts, _, role, content)| SessionMessage {
|
||||
role,
|
||||
content,
|
||||
ts: if ts > 0 { Some(ts) } else { None },
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn parse_session(storage: &Path, path: &Path) -> Option<SessionMeta> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
let value: Value = serde_json::from_str(&data).ok()?;
|
||||
|
||||
let session_id = value.get("id").and_then(Value::as_str)?.to_string();
|
||||
let title = value
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let directory = value
|
||||
.get("directory")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let created_at = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(parse_timestamp_to_ms);
|
||||
let updated_at = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("updated"))
|
||||
.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()
|
||||
.and_then(path_basename)
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Build source_path = message directory for this session
|
||||
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)
|
||||
};
|
||||
|
||||
Some(SessionMeta {
|
||||
provider_id: PROVIDER_ID.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
title: display_title,
|
||||
summary,
|
||||
project_dir: directory,
|
||||
created_at,
|
||||
last_active_at: updated_at.or(created_at),
|
||||
source_path: Some(source_path),
|
||||
resume_command: Some(format!("opencode session resume {session_id}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the first user message's first text part to use as summary.
|
||||
fn get_first_user_summary(storage: &Path, session_id: &str) -> Option<String> {
|
||||
let msg_dir = storage.join("message").join(session_id);
|
||||
if !msg_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut msg_files = Vec::new();
|
||||
collect_json_files(&msg_dir, &mut msg_files);
|
||||
|
||||
// Collect user messages with timestamps for ordering
|
||||
let mut user_msgs: Vec<(i64, String)> = Vec::new();
|
||||
for msg_path in &msg_files {
|
||||
let data = match std::fs::read_to_string(msg_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("role").and_then(Value::as_str) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let msg_id = match value.get("id").and_then(Value::as_str) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let ts = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(parse_timestamp_to_ms)
|
||||
.unwrap_or(0);
|
||||
|
||||
user_msgs.push((ts, msg_id));
|
||||
}
|
||||
|
||||
user_msgs.sort_by_key(|(ts, _)| *ts);
|
||||
|
||||
// Take first user message and get its parts
|
||||
let (_, first_id) = user_msgs.first()?;
|
||||
let part_dir = storage.join("part").join(first_id);
|
||||
let text = collect_parts_text(&part_dir);
|
||||
if text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(truncate_summary(&text, 160))
|
||||
}
|
||||
|
||||
/// Collect text content from all parts in a part directory.
|
||||
fn collect_parts_text(part_dir: &Path) -> String {
|
||||
if !part_dir.is_dir() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
collect_json_files(part_dir, &mut parts);
|
||||
|
||||
let mut texts = Vec::new();
|
||||
for part_path in &parts {
|
||||
let data = match std::fs::read_to_string(part_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Only include text-type parts
|
||||
if value.get("type").and_then(Value::as_str) != Some("text") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = value.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
texts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
texts.join("\n")
|
||||
}
|
||||
|
||||
fn collect_json_files(root: &Path, files: &mut Vec<PathBuf>) {
|
||||
if !root.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_json_files(&path, files);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,50 @@
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Read the first `head_n` lines and last `tail_n` lines from a file.
|
||||
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
|
||||
pub fn read_head_tail_lines(
|
||||
path: &Path,
|
||||
head_n: usize,
|
||||
tail_n: usize,
|
||||
) -> io::Result<(Vec<String>, Vec<String>)> {
|
||||
let file = File::open(path)?;
|
||||
let file_len = file.metadata()?.len();
|
||||
|
||||
// For small files, read all lines once and split
|
||||
if file_len < 16_384 {
|
||||
let reader = BufReader::new(file);
|
||||
let all: Vec<String> = reader.lines().map_while(Result::ok).collect();
|
||||
let head = all.iter().take(head_n).cloned().collect();
|
||||
let skip = all.len().saturating_sub(tail_n);
|
||||
let tail = all.into_iter().skip(skip).collect();
|
||||
return Ok((head, tail));
|
||||
}
|
||||
|
||||
// Read head lines from the beginning
|
||||
let reader = BufReader::new(file);
|
||||
let head: Vec<String> = reader.lines().take(head_n).map_while(Result::ok).collect();
|
||||
|
||||
// Seek to last ~16 KB for tail lines
|
||||
let seek_pos = file_len.saturating_sub(16_384);
|
||||
let mut file2 = File::open(path)?;
|
||||
file2.seek(SeekFrom::Start(seek_pos))?;
|
||||
let tail_reader = BufReader::new(file2);
|
||||
let all_tail: Vec<String> = tail_reader.lines().map_while(Result::ok).collect();
|
||||
|
||||
// Skip first partial line if we seeked into the middle of a line
|
||||
let skip_first = if seek_pos > 0 { 1 } else { 0 };
|
||||
let usable: Vec<String> = all_tail.into_iter().skip(skip_first).collect();
|
||||
let skip = usable.len().saturating_sub(tail_n);
|
||||
let tail = usable.into_iter().skip(skip).collect();
|
||||
|
||||
Ok((head, tail))
|
||||
}
|
||||
|
||||
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
|
||||
let raw = value.as_str()?;
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
|
||||
@@ -187,6 +187,15 @@ pub struct AppSettings {
|
||||
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||||
#[serde(default)]
|
||||
pub silent_startup: bool,
|
||||
/// 是否在主页面启用本地代理功能(默认关闭)
|
||||
#[serde(default)]
|
||||
pub enable_local_proxy: bool,
|
||||
/// User has confirmed the local proxy first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proxy_confirmed: Option<bool>,
|
||||
/// User has confirmed the usage query first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage_confirmed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
@@ -236,6 +245,14 @@ pub struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_backup: Option<serde_json::Value>,
|
||||
|
||||
// ===== 备份策略设置 =====
|
||||
/// Auto-backup interval in hours (default 24, 0 = disabled)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backup_interval_hours: Option<u32>,
|
||||
/// Maximum number of backup files to retain (default 10)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backup_retain_count: Option<u32>,
|
||||
|
||||
// ===== 终端设置 =====
|
||||
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
@@ -262,6 +279,9 @@ impl Default for AppSettings {
|
||||
skip_claude_onboarding: false,
|
||||
launch_on_startup: false,
|
||||
silent_startup: false,
|
||||
enable_local_proxy: false,
|
||||
proxy_confirmed: None,
|
||||
usage_confirmed: None,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
claude_config_dir: None,
|
||||
@@ -277,6 +297,8 @@ impl Default for AppSettings {
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
webdav_sync: None,
|
||||
webdav_backup: None,
|
||||
backup_interval_hours: None,
|
||||
backup_retain_count: None,
|
||||
preferred_terminal: None,
|
||||
}
|
||||
}
|
||||
@@ -611,6 +633,33 @@ pub fn get_skill_sync_method() -> SyncMethod {
|
||||
.skill_sync_method
|
||||
}
|
||||
|
||||
// ===== 备份策略管理函数 =====
|
||||
|
||||
/// Get the effective auto-backup interval in hours (default 24)
|
||||
pub fn effective_backup_interval_hours() -> u32 {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.backup_interval_hours
|
||||
.unwrap_or(24)
|
||||
}
|
||||
|
||||
/// Get the effective backup retain count (default 10, minimum 1)
|
||||
pub fn effective_backup_retain_count() -> usize {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.backup_retain_count
|
||||
.map(|n| (n as usize).max(1))
|
||||
.unwrap_or(10)
|
||||
}
|
||||
|
||||
// ===== 终端设置管理函数 =====
|
||||
|
||||
/// 获取首选终端应用
|
||||
|
||||
+9
-23
@@ -15,7 +15,7 @@ pub struct TrayTexts {
|
||||
pub show_main: &'static str,
|
||||
pub no_provider_hint: &'static str,
|
||||
pub quit: &'static str,
|
||||
pub auto_label: &'static str,
|
||||
pub _auto_label: &'static str,
|
||||
}
|
||||
|
||||
impl TrayTexts {
|
||||
@@ -25,20 +25,20 @@ impl TrayTexts {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
quit: "Quit",
|
||||
auto_label: "Auto (Failover)",
|
||||
_auto_label: "Auto (Failover)",
|
||||
},
|
||||
"ja" => Self {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
quit: "終了",
|
||||
auto_label: "自動 (フェイルオーバー)",
|
||||
_auto_label: "自動 (フェイルオーバー)",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
quit: "退出",
|
||||
auto_label: "自动 (故障转移)",
|
||||
_auto_label: "自动 (故障转移)",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ fn append_provider_section<'a>(
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
app_state: &AppState,
|
||||
_app_state: &AppState,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
@@ -119,22 +119,9 @@ fn append_provider_section<'a>(
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
// 获取 proxy 状态,决定 Auto 是否选中
|
||||
let (proxy_enabled, auto_failover) =
|
||||
app_state.db.get_proxy_flags_sync(section.app_type.as_str());
|
||||
let auto_mode = proxy_enabled && auto_failover;
|
||||
|
||||
// 添加 Auto 菜单项(始终显示在供应商列表前)
|
||||
let auto_item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, AUTO_SUFFIX),
|
||||
tray_texts.auto_label,
|
||||
true,
|
||||
auto_mode,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}Auto菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&auto_item);
|
||||
// Auto (Failover) menu item is hidden from tray; the feature is still
|
||||
// accessible from the Settings page. Keep the surrounding code intact so
|
||||
// it can be re-enabled easily in the future.
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
@@ -156,8 +143,7 @@ fn append_provider_section<'a>(
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
// Auto 模式下所有供应商都不选中
|
||||
let is_current = !auto_mode && manager.current == *id;
|
||||
let is_current = manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.10.3",
|
||||
"version": "3.11.1",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+292
-221
@@ -35,6 +35,7 @@ import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
@@ -61,7 +62,10 @@ import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { McpIcon } from "@/components/BrandIcons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
||||
import { useDisableCurrentOmo } from "@/lib/query/omo";
|
||||
import {
|
||||
useDisableCurrentOmo,
|
||||
useDisableCurrentOmoSlim,
|
||||
} from "@/lib/query/omo";
|
||||
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
||||
import EnvPanel from "@/components/openclaw/EnvPanel";
|
||||
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||
@@ -176,7 +180,10 @@ function App() {
|
||||
if (
|
||||
currentView === "sessions" &&
|
||||
activeApp !== "claude" &&
|
||||
activeApp !== "codex"
|
||||
activeApp !== "codex" &&
|
||||
activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
activeApp !== "gemini"
|
||||
) {
|
||||
setCurrentView("providers");
|
||||
}
|
||||
@@ -194,6 +201,9 @@ function App() {
|
||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const isToolbarCompact = useAutoCompact(toolbarRef);
|
||||
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
const skillsPageRef = useRef<any>(null);
|
||||
@@ -220,7 +230,12 @@ function App() {
|
||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||
const currentProviderId = data?.currentProviderId ?? "";
|
||||
const hasSkillsSupport = true;
|
||||
const hasSessionSupport = activeApp === "claude" || activeApp === "codex";
|
||||
const hasSessionSupport =
|
||||
activeApp === "claude" ||
|
||||
activeApp === "codex" ||
|
||||
activeApp === "opencode" ||
|
||||
activeApp === "openclaw" ||
|
||||
activeApp === "gemini";
|
||||
|
||||
const {
|
||||
addProvider,
|
||||
@@ -248,6 +263,23 @@ function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const disableOmoSlimMutation = useDisableCurrentOmoSlim();
|
||||
const handleDisableOmoSlim = () => {
|
||||
disableOmoSlimMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" }));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
t("omo.disableFailed", {
|
||||
defaultValue: "停用 OMO 失败: {{error}}",
|
||||
error: extractErrorMessage(error),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
@@ -308,7 +340,8 @@ function App() {
|
||||
const off = await listen(
|
||||
"webdav-sync-status-updated",
|
||||
async (event) => {
|
||||
const payload = (event.payload ?? {}) as WebDavSyncStatusUpdatedPayload;
|
||||
const payload = (event.payload ??
|
||||
{}) as WebDavSyncStatusUpdatedPayload;
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
|
||||
if (payload.source !== "auto" || payload.status !== "error") {
|
||||
@@ -697,7 +730,7 @@ function App() {
|
||||
);
|
||||
|
||||
case "sessions":
|
||||
return <SessionManagerPage />;
|
||||
return <SessionManagerPage key={activeApp} appId={activeApp} />;
|
||||
case "workspace":
|
||||
return <WorkspaceFilesPanel />;
|
||||
case "openclawEnv":
|
||||
@@ -745,6 +778,11 @@ function App() {
|
||||
onDisableOmo={
|
||||
activeApp === "opencode" ? handleDisableOmo : undefined
|
||||
}
|
||||
onDisableOmoSlim={
|
||||
activeApp === "opencode"
|
||||
? handleDisableOmoSlim
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
@@ -926,229 +964,262 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 h-[32px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
<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
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("prompts.add")}
|
||||
</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" && (
|
||||
<>
|
||||
{activeApp !== "opencode" && activeApp !== "openclaw" && (
|
||||
{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" && (
|
||||
<>
|
||||
<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",
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mcpPanelRef.current?.openImport()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<FailoverToggle activeApp={activeApp} />
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={
|
||||
isCurrentAppTakeoverActive &&
|
||||
Object.values(visibleApps).filter(Boolean).length >= 4
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
{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("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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppId;
|
||||
@@ -52,18 +53,28 @@ export function AppSwitcher({
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(app)}
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
className={cn(
|
||||
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
|
||||
activeApp === app
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName[app]}
|
||||
name={appDisplayName[app]}
|
||||
size={iconSize}
|
||||
/>
|
||||
{!compact && <span>{appDisplayName[app]}</span>}
|
||||
<span
|
||||
className={cn(
|
||||
"transition-all duration-200 whitespace-nowrap overflow-hidden",
|
||||
compact
|
||||
? "max-w-0 opacity-0 ml-0"
|
||||
: "max-w-[80px] opacity-100 ml-2",
|
||||
)}
|
||||
>
|
||||
{appDisplayName[app]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -16,6 +16,7 @@ interface ConfirmDialogProps {
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "destructive" | "info";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -26,11 +27,16 @@ export function ConfirmDialog({
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = "destructive",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const IconComponent = variant === "info" ? Info : AlertTriangle;
|
||||
const iconClass =
|
||||
variant === "info" ? "h-5 w-5 text-blue-500" : "h-5 w-5 text-destructive";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
@@ -43,7 +49,7 @@ export function ConfirmDialog({
|
||||
<DialogContent className="max-w-sm" zIndex="alert">
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
<IconComponent className={iconClass} />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
||||
@@ -54,7 +60,10 @@ export function ConfirmDialog({
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{cancelText || t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
<Button
|
||||
variant={variant === "info" ? "default" : "destructive"}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText || t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -53,15 +53,12 @@ export function DeepLinkImportDialog() {
|
||||
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||
"deeplink-import",
|
||||
async (event) => {
|
||||
console.log("Deep link import event received:", event.payload);
|
||||
|
||||
// If config is present, merge it to get the complete configuration
|
||||
if (event.payload.config || event.payload.configUrl) {
|
||||
try {
|
||||
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
||||
event.payload,
|
||||
);
|
||||
console.log("Config merged successfully:", mergedRequest);
|
||||
setRequest(mergedRequest);
|
||||
} catch (error) {
|
||||
console.error("Failed to merge config:", error);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Provider, UsageScript, UsageData } from "@/types";
|
||||
import { usageApi, type AppId } from "@/lib/api";
|
||||
import { usageApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
@@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UsageScriptModalProps {
|
||||
@@ -112,6 +114,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settingsData } = useSettingsQuery();
|
||||
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
|
||||
|
||||
// 生成带国际化的预设模板
|
||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||
@@ -247,6 +251,27 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showAccessToken, setShowAccessToken] = useState(false);
|
||||
|
||||
const handleEnableToggle = (checked: boolean) => {
|
||||
if (checked && !settingsData?.usageConfirmed) {
|
||||
setShowUsageConfirm(true);
|
||||
} else {
|
||||
setScript({ ...script, enabled: checked });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsageConfirm = async () => {
|
||||
setShowUsageConfirm(false);
|
||||
try {
|
||||
if (settingsData) {
|
||||
await settingsApi.save({ ...settingsData, usageConfirmed: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save usage confirmed:", error);
|
||||
}
|
||||
setScript({ ...script, enabled: true });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
toast.error(t("usageScript.scriptEmpty"));
|
||||
@@ -436,9 +461,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</p>
|
||||
<Switch
|
||||
checked={script.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setScript({ ...script, enabled: checked })
|
||||
}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
aria-label={t("usageScript.enableUsageQuery")}
|
||||
/>
|
||||
</div>
|
||||
@@ -844,6 +867,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showUsageConfirm}
|
||||
variant="info"
|
||||
title={t("confirm.usage.title")}
|
||||
message={t("confirm.usage.message")}
|
||||
confirmText={t("confirm.usage.confirm")}
|
||||
onConfirm={() => void handleUsageConfirm()}
|
||||
onCancel={() => setShowUsageConfirm(false)}
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,11 +6,13 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
interface AppCountBarProps {
|
||||
totalLabel: string;
|
||||
counts: Record<AppId, number>;
|
||||
appIds?: AppId[];
|
||||
}
|
||||
|
||||
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||
totalLabel,
|
||||
counts,
|
||||
appIds = APP_IDS,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6 flex items-center justify-between gap-4">
|
||||
@@ -18,7 +20,7 @@ export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||
{totalLabel}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||
{APP_IDS.map((app) => (
|
||||
{appIds.map((app) => (
|
||||
<Badge
|
||||
key={app}
|
||||
variant="secondary"
|
||||
|
||||
@@ -10,15 +10,17 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
||||
interface AppToggleGroupProps {
|
||||
apps: Record<AppId, boolean>;
|
||||
onToggle: (app: AppId, enabled: boolean) => void;
|
||||
appIds?: AppId[];
|
||||
}
|
||||
|
||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||
apps,
|
||||
onToggle,
|
||||
appIds = APP_IDS,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{APP_IDS.map((app) => {
|
||||
{appIds.map((app) => {
|
||||
const { label, icon, activeClass } = APP_ICON_MAP[app];
|
||||
const enabled = apps[app];
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { mcpPresets } from "@/config/mcpPresets";
|
||||
import { toast } from "sonner";
|
||||
import { APP_IDS } from "@/config/appConfig";
|
||||
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
@@ -58,7 +58,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
const enabledCounts = useMemo(() => {
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||
serverEntries.forEach(([_, server]) => {
|
||||
for (const app of APP_IDS) {
|
||||
for (const app of MCP_SKILLS_APP_IDS) {
|
||||
if (server.apps[app]) counts[app]++;
|
||||
}
|
||||
});
|
||||
@@ -136,6 +136,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
<AppCountBar
|
||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||
counts={enabledCounts}
|
||||
appIds={MCP_SKILLS_APP_IDS}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
@@ -277,6 +278,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
<AppToggleGroup
|
||||
apps={server.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
||||
appIds={MCP_SKILLS_APP_IDS}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -17,7 +17,6 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
const { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
|
||||
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
||||
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
|
||||
const [primaryModel, setPrimaryModel] = useState("");
|
||||
const [fallbacks, setFallbacks] = useState("");
|
||||
|
||||
// Extra known fields from agents.defaults
|
||||
@@ -26,13 +25,15 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
const [contextTokens, setContextTokens] = useState("");
|
||||
const [maxConcurrent, setMaxConcurrent] = useState("");
|
||||
|
||||
// Primary model is read-only — set via the "Set as default model" button on provider cards
|
||||
const primaryModel = agentsData?.model?.primary ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
// agentsData is undefined while loading, null when config section is absent
|
||||
if (agentsData === undefined) return;
|
||||
setDefaults(agentsData);
|
||||
|
||||
if (agentsData) {
|
||||
setPrimaryModel(agentsData.model?.primary ?? "");
|
||||
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
|
||||
|
||||
// Extract known extra fields
|
||||
@@ -48,17 +49,21 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
// Preserve all unknown fields from original data
|
||||
const updated: OpenClawAgentsDefaults = { ...defaults };
|
||||
|
||||
// Model configuration
|
||||
// Model configuration — primary is read-only, preserve original value
|
||||
const fallbackList = fallbacks
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (primaryModel.trim()) {
|
||||
const origPrimary = defaults?.model?.primary;
|
||||
if (origPrimary) {
|
||||
updated.model = {
|
||||
primary: primaryModel.trim(),
|
||||
primary: origPrimary,
|
||||
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
|
||||
};
|
||||
} else if (fallbackList.length > 0) {
|
||||
// No primary set but user provided fallbacks — keep fallbacks only
|
||||
updated.model = { primary: "", fallbacks: fallbackList };
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
@@ -122,12 +127,9 @@ const AgentsDefaultsPanel: React.FC = () => {
|
||||
<Label className="mb-1.5 block">
|
||||
{t("openclaw.agents.primaryModel")}
|
||||
</Label>
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => setPrimaryModel(e.target.value)}
|
||||
placeholder="provider/model-id"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted/50 font-mono text-xs text-muted-foreground">
|
||||
{primaryModel || t("openclaw.agents.notSet")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("openclaw.agents.primaryModelHint")}
|
||||
</p>
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
Check,
|
||||
Copy,
|
||||
Edit,
|
||||
Loader2,
|
||||
// Loader2, // Hidden: stream check feature disabled
|
||||
Minus,
|
||||
Play,
|
||||
Plus,
|
||||
Terminal,
|
||||
TestTube2,
|
||||
// TestTube2, // Hidden: stream check feature disabled
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -24,7 +24,6 @@ interface ProviderActionsProps {
|
||||
isTesting?: boolean;
|
||||
isProxyTakeover?: boolean;
|
||||
isOmo?: boolean;
|
||||
isLastOmo?: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
@@ -46,14 +45,13 @@ export function ProviderActions({
|
||||
appId,
|
||||
isCurrent,
|
||||
isInConfig = false,
|
||||
isTesting,
|
||||
isTesting: _isTesting, // Hidden: stream check feature disabled
|
||||
isProxyTakeover = false,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onTest,
|
||||
onTest: _onTest, // Hidden: stream check feature disabled
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
@@ -192,11 +190,7 @@ export function ProviderActions({
|
||||
|
||||
const buttonState = getMainButtonState();
|
||||
|
||||
const canDelete = isOmo
|
||||
? !(isLastOmo && isCurrent)
|
||||
: isAdditiveMode
|
||||
? true
|
||||
: !isCurrent;
|
||||
const canDelete = isOmo || isAdditiveMode ? true : !isCurrent;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -207,7 +201,7 @@ export function ProviderActions({
|
||||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||||
disabled={isDefaultModel}
|
||||
className={cn(
|
||||
"w-[4.5rem] px-2.5",
|
||||
"w-fit px-2.5",
|
||||
isDefaultModel
|
||||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
||||
: "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
@@ -215,8 +209,8 @@ export function ProviderActions({
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
{isDefaultModel
|
||||
? t("provider.isDefault", { defaultValue: "默认" })
|
||||
: t("provider.setAsDefault", { defaultValue: "启用" })}
|
||||
? t("provider.isDefault", { defaultValue: "当前默认" })
|
||||
: t("provider.setAsDefault", { defaultValue: "设为默认" })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -252,6 +246,7 @@ export function ProviderActions({
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Hidden: stream check feature disabled
|
||||
{onTest && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -268,6 +263,7 @@ export function ProviderActions({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
*/}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -28,12 +28,13 @@ interface ProviderCardProps {
|
||||
appId: AppId;
|
||||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||
isOmo?: boolean;
|
||||
isLastOmo?: boolean;
|
||||
isOmoSlim?: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDisableOmoSlim?: () => void;
|
||||
onConfigureUsage: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
@@ -91,12 +92,13 @@ export function ProviderCard({
|
||||
appId,
|
||||
isInConfig = true,
|
||||
isOmo = false,
|
||||
isLastOmo = false,
|
||||
isOmoSlim = false,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDisableOmoSlim,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onDuplicate,
|
||||
@@ -117,6 +119,10 @@ export function ProviderCard({
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// OMO and OMO Slim share the same card behavior
|
||||
const isAnyOmo = isOmo || isOmoSlim;
|
||||
const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo;
|
||||
|
||||
const { data: health } = useProviderHealth(provider.id, appId);
|
||||
|
||||
const fallbackUrlText = t("provider.notConfigured", {
|
||||
@@ -186,11 +192,11 @@ export function ProviderCard({
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - OMO 供应商:使用 isCurrent
|
||||
// - OMO/OMO Slim 供应商:使用 isCurrent
|
||||
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isOmo
|
||||
const isActiveProvider = isAnyOmo
|
||||
? isCurrent
|
||||
: appId === "opencode" || appId === "openclaw"
|
||||
? false
|
||||
@@ -198,10 +204,10 @@ export function ProviderCard({
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
|
||||
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue =
|
||||
(isOmo && isActiveProvider) ||
|
||||
(!isOmo && !isProxyTakeover && isActiveProvider);
|
||||
(isAnyOmo && isActiveProvider) ||
|
||||
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -265,6 +271,12 @@ export function ProviderCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isOmoSlim && (
|
||||
<span className="inline-flex items-center rounded-md bg-indigo-100 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||
Slim
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
@@ -372,8 +384,7 @@ export function ProviderCard({
|
||||
isInConfig={isInConfig}
|
||||
isTesting={isTesting}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={isLastOmo}
|
||||
isOmo={isAnyOmo}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
@@ -385,7 +396,7 @@ export function ProviderCard({
|
||||
? () => onRemoveFromConfig(provider)
|
||||
: undefined
|
||||
}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onDisableOmo={handleDisableAnyOmo}
|
||||
onOpenTerminal={
|
||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { Download, Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ProviderEmptyStateProps {
|
||||
onCreate?: () => void;
|
||||
onImport?: () => void;
|
||||
}
|
||||
|
||||
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
||||
export function ProviderEmptyState({
|
||||
onCreate,
|
||||
onImport,
|
||||
}: ProviderEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -18,11 +22,19 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{t("provider.noProvidersDescription")}
|
||||
</p>
|
||||
{onCreate && (
|
||||
<Button className="mt-6" onClick={onCreate}>
|
||||
{t("provider.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{onImport && (
|
||||
<Button onClick={onImport}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t("provider.importCurrent")}
|
||||
</Button>
|
||||
)}
|
||||
{onCreate && (
|
||||
<Button variant={onImport ? "outline" : "default"} onClick={onCreate}>
|
||||
{t("provider.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providersApi } from "@/lib/api/providers";
|
||||
@@ -33,7 +34,10 @@ import {
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
} from "@/lib/query/failover";
|
||||
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
|
||||
import {
|
||||
useCurrentOmoProviderId,
|
||||
useCurrentOmoSlimProviderId,
|
||||
} from "@/lib/query/omo";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -47,6 +51,7 @@ interface ProviderListProps {
|
||||
onDelete: (provider: Provider) => void;
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDisableOmoSlim?: () => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
@@ -68,6 +73,7 @@ export function ProviderList({
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDisableOmoSlim,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
@@ -134,7 +140,7 @@ export function ProviderList({
|
||||
|
||||
const isOpenCode = appId === "opencode";
|
||||
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
||||
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
|
||||
const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);
|
||||
|
||||
const getFailoverPriority = useCallback(
|
||||
(providerId: string): number | undefined => {
|
||||
@@ -170,6 +176,33 @@ export function ProviderList({
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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);
|
||||
},
|
||||
onSuccess: (imported) => {
|
||||
if (imported) {
|
||||
queryClient.invalidateQueries({ queryKey: ["providers", appId] });
|
||||
toast.success(t("provider.importCurrentDescription"));
|
||||
} else {
|
||||
toast.info(t("provider.noProviders"));
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
@@ -223,7 +256,12 @@ export function ProviderList({
|
||||
}
|
||||
|
||||
if (sortedProviders.length === 0) {
|
||||
return <ProviderEmptyState onCreate={onCreate} />;
|
||||
return (
|
||||
<ProviderEmptyState
|
||||
onCreate={onCreate}
|
||||
onImport={() => importMutation.mutate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const renderProviderList = () => (
|
||||
@@ -239,25 +277,31 @@ export function ProviderList({
|
||||
<div className="space-y-3">
|
||||
{filteredProviders.map((provider) => {
|
||||
const isOmo = provider.category === "omo";
|
||||
const isOmoSlim = provider.category === "omo-slim";
|
||||
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
||||
const isOmoSlimCurrent =
|
||||
isOmoSlim && provider.id === (currentOmoSlimId || "");
|
||||
return (
|
||||
<SortableProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isCurrent={
|
||||
isOmo ? isOmoCurrent : provider.id === currentProviderId
|
||||
isOmo
|
||||
? isOmoCurrent
|
||||
: isOmoSlim
|
||||
? isOmoSlimCurrent
|
||||
: provider.id === currentProviderId
|
||||
}
|
||||
appId={appId}
|
||||
isInConfig={isProviderInConfig(provider.id)}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={
|
||||
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
||||
}
|
||||
isOmoSlim={isOmoSlim}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onDisableOmoSlim={onDisableOmoSlim}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
@@ -370,12 +414,13 @@ interface SortableProviderCardProps {
|
||||
appId: AppId;
|
||||
isInConfig: boolean;
|
||||
isOmo: boolean;
|
||||
isLastOmo: boolean;
|
||||
isOmoSlim: boolean;
|
||||
onSwitch: (provider: Provider) => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
onRemoveFromConfig?: (provider: Provider) => void;
|
||||
onDisableOmo?: () => void;
|
||||
onDisableOmoSlim?: () => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
@@ -400,12 +445,13 @@ function SortableProviderCard({
|
||||
appId,
|
||||
isInConfig,
|
||||
isOmo,
|
||||
isLastOmo,
|
||||
isOmoSlim,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveFromConfig,
|
||||
onDisableOmo,
|
||||
onDisableOmoSlim,
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
@@ -444,12 +490,13 @@ function SortableProviderCard({
|
||||
appId={appId}
|
||||
isInConfig={isInConfig}
|
||||
isOmo={isOmo}
|
||||
isLastOmo={isLastOmo}
|
||||
isOmoSlim={isOmoSlim}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRemoveFromConfig={onRemoveFromConfig}
|
||||
onDisableOmo={onDisableOmo}
|
||||
onDisableOmoSlim={onDisableOmoSlim}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={
|
||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||
|
||||
@@ -188,8 +188,8 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API 格式选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
{/* API 格式选择(仅非官方、非云服务商显示) */}
|
||||
{shouldShowModelSelector && category !== "cloud_provider" && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, FolderInput, Loader2 } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import {
|
||||
OmoGlobalConfigFields,
|
||||
type OmoGlobalConfigFieldsRef,
|
||||
} from "./OmoGlobalConfigFields";
|
||||
import type { OmoGlobalConfig } from "@/types/omo";
|
||||
|
||||
interface OmoCommonConfigEditorProps {
|
||||
previewValue: string;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
isModalOpen: boolean;
|
||||
onEditClick: () => void;
|
||||
onModalClose: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
onGlobalConfigStateChange: (config: OmoGlobalConfig) => void;
|
||||
globalConfigRef: React.RefObject<OmoGlobalConfigFieldsRef | null>;
|
||||
fieldsKey: number;
|
||||
}
|
||||
|
||||
export function OmoCommonConfigEditor({
|
||||
previewValue,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
isModalOpen,
|
||||
onEditClick,
|
||||
onModalClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
onGlobalConfigStateChange,
|
||||
globalConfigRef,
|
||||
fieldsKey,
|
||||
}: 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
|
||||
/>
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -41,10 +41,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useReadOmoLocalFile } from "@/lib/query/omo";
|
||||
import { useReadOmoLocalFile, useReadOmoSlimLocalFile } from "@/lib/query/omo";
|
||||
import {
|
||||
OMO_BUILTIN_AGENTS,
|
||||
OMO_BUILTIN_CATEGORIES,
|
||||
OMO_SLIM_BUILTIN_AGENTS,
|
||||
type OmoAgentDef,
|
||||
type OmoCategoryDef,
|
||||
} from "@/types/omo";
|
||||
@@ -69,12 +70,13 @@ interface OmoFormFieldsProps {
|
||||
>;
|
||||
agents: Record<string, Record<string, unknown>>;
|
||||
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
|
||||
categories: Record<string, Record<string, unknown>>;
|
||||
onCategoriesChange: (
|
||||
categories?: Record<string, Record<string, unknown>>;
|
||||
onCategoriesChange?: (
|
||||
categories: Record<string, Record<string, unknown>>,
|
||||
) => void;
|
||||
otherFieldsStr: string;
|
||||
onOtherFieldsStrChange: (value: string) => void;
|
||||
isSlim?: boolean;
|
||||
}
|
||||
|
||||
export type CustomModelItem = {
|
||||
@@ -121,6 +123,9 @@ function DeferredKeyInput({
|
||||
}
|
||||
|
||||
const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key));
|
||||
const BUILTIN_AGENT_KEYS_SLIM = new Set(
|
||||
OMO_SLIM_BUILTIN_AGENTS.map((a) => a.key),
|
||||
);
|
||||
const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
|
||||
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
|
||||
|
||||
@@ -303,13 +308,21 @@ export function OmoFormFields({
|
||||
presetMetaMap: _presetMetaMap = {},
|
||||
agents,
|
||||
onAgentsChange,
|
||||
categories,
|
||||
categories = {},
|
||||
onCategoriesChange,
|
||||
otherFieldsStr,
|
||||
onOtherFieldsStrChange,
|
||||
isSlim = false,
|
||||
}: OmoFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const builtinAgentDefs = isSlim
|
||||
? OMO_SLIM_BUILTIN_AGENTS
|
||||
: OMO_BUILTIN_AGENTS;
|
||||
const builtinAgentKeys = isSlim
|
||||
? BUILTIN_AGENT_KEYS_SLIM
|
||||
: BUILTIN_AGENT_KEYS;
|
||||
|
||||
const [mainAgentsOpen, setMainAgentsOpen] = useState(true);
|
||||
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
||||
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
||||
@@ -329,7 +342,7 @@ export function OmoFormFields({
|
||||
>({});
|
||||
|
||||
const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>
|
||||
collectCustomModels(agents, BUILTIN_AGENT_KEYS),
|
||||
collectCustomModels(agents, builtinAgentKeys),
|
||||
);
|
||||
|
||||
const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(
|
||||
@@ -337,7 +350,7 @@ export function OmoFormFields({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS));
|
||||
setCustomAgents(collectCustomModels(agents, builtinAgentKeys));
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -349,17 +362,18 @@ export function OmoFormFields({
|
||||
onAgentsChange(
|
||||
mergeCustomModelsIntoStore(
|
||||
agents,
|
||||
BUILTIN_AGENT_KEYS,
|
||||
builtinAgentKeys,
|
||||
customs,
|
||||
modelVariantsMap,
|
||||
),
|
||||
);
|
||||
},
|
||||
[agents, onAgentsChange, modelVariantsMap],
|
||||
[agents, onAgentsChange, modelVariantsMap, builtinAgentKeys],
|
||||
);
|
||||
|
||||
const syncCustomCategories = useCallback(
|
||||
(customs: CustomModelItem[]) => {
|
||||
if (!onCategoriesChange) return;
|
||||
onCategoriesChange(
|
||||
mergeCustomModelsIntoStore(
|
||||
categories,
|
||||
@@ -708,42 +722,91 @@ export function OmoFormFields({
|
||||
return;
|
||||
}
|
||||
|
||||
let filledCount = 0;
|
||||
let alreadySetCount = 0;
|
||||
let unmatchedCount = 0;
|
||||
|
||||
const updatedAgents = { ...agents };
|
||||
for (const agentDef of OMO_BUILTIN_AGENTS) {
|
||||
for (const agentDef of builtinAgentDefs) {
|
||||
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
|
||||
if (recommendedValue && !updatedAgents[agentDef.key]?.model) {
|
||||
if (!recommendedValue) {
|
||||
unmatchedCount++;
|
||||
} else if (updatedAgents[agentDef.key]?.model) {
|
||||
alreadySetCount++;
|
||||
} else {
|
||||
updatedAgents[agentDef.key] = {
|
||||
...updatedAgents[agentDef.key],
|
||||
model: recommendedValue,
|
||||
};
|
||||
filledCount++;
|
||||
}
|
||||
}
|
||||
onAgentsChange(updatedAgents);
|
||||
|
||||
const updatedCategories = { ...categories };
|
||||
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
||||
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
||||
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
|
||||
updatedCategories[catDef.key] = {
|
||||
...updatedCategories[catDef.key],
|
||||
model: recommendedValue,
|
||||
};
|
||||
if (!isSlim && onCategoriesChange) {
|
||||
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 {
|
||||
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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
onCategoriesChange(updatedCategories);
|
||||
};
|
||||
|
||||
const configuredAgentCount = Object.keys(agents).length;
|
||||
const configuredCategoryCount = Object.keys(categories).length;
|
||||
const mainAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "main");
|
||||
const subAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "sub");
|
||||
const configuredCategoryCount = isSlim ? 0 : Object.keys(categories).length;
|
||||
const mainAgents = builtinAgentDefs.filter((a) => a.group === "main");
|
||||
const subAgents = builtinAgentDefs.filter((a) => a.group === "sub");
|
||||
|
||||
const readLocalFile = useReadOmoLocalFile();
|
||||
const readSlimLocalFile = useReadOmoSlimLocalFile();
|
||||
const [localFilePath, setLocalFilePath] = useState<string | null>(null);
|
||||
|
||||
const handleImportFromLocal = useCallback(async () => {
|
||||
try {
|
||||
const data = await readLocalFile.mutateAsync();
|
||||
const data = isSlim
|
||||
? await readSlimLocalFile.mutateAsync()
|
||||
: await readLocalFile.mutateAsync();
|
||||
const importedAgents =
|
||||
(data.agents as Record<string, Record<string, unknown>> | undefined) ||
|
||||
{};
|
||||
@@ -753,16 +816,20 @@ export function OmoFormFields({
|
||||
| undefined) || {};
|
||||
|
||||
onAgentsChange(importedAgents);
|
||||
onCategoriesChange(importedCategories);
|
||||
if (!isSlim && onCategoriesChange) {
|
||||
onCategoriesChange(importedCategories);
|
||||
}
|
||||
onOtherFieldsStrChange(
|
||||
data.otherFields ? JSON.stringify(data.otherFields, null, 2) : "",
|
||||
);
|
||||
setAgentAdvancedDrafts({});
|
||||
setCategoryAdvancedDrafts({});
|
||||
setCustomAgents(collectCustomModels(importedAgents, BUILTIN_AGENT_KEYS));
|
||||
setCustomCategories(
|
||||
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
||||
);
|
||||
setCustomAgents(collectCustomModels(importedAgents, builtinAgentKeys));
|
||||
if (!isSlim) {
|
||||
setCustomCategories(
|
||||
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
||||
);
|
||||
}
|
||||
setLocalFilePath(data.filePath);
|
||||
toast.success(
|
||||
t("omo.importLocalReplaceSuccess", {
|
||||
@@ -792,7 +859,7 @@ export function OmoFormFields({
|
||||
) => {
|
||||
const isAgent = scope === "agent";
|
||||
const store = isAgent ? agents : categories;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||
|
||||
@@ -866,7 +933,7 @@ export function OmoFormFields({
|
||||
) => {
|
||||
const isAgent = scope === "agent";
|
||||
const store = isAgent ? agents : categories;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
||||
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||
const customs = isAgent ? customAgents : customCategories;
|
||||
@@ -1153,30 +1220,31 @@ export function OmoFormFields({
|
||||
),
|
||||
})}
|
||||
|
||||
{renderModelSection({
|
||||
title: t("omo.categories", { defaultValue: "Categories" }),
|
||||
isOpen: categoriesOpen,
|
||||
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
||||
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
||||
action: renderCustomAddButton(() => addCustomModel("category")),
|
||||
children: (
|
||||
<>
|
||||
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
||||
{customCategories.length > 0 && (
|
||||
<>
|
||||
{renderCustomDivider(
|
||||
t("omo.customCategories", {
|
||||
defaultValue: "Custom Categories",
|
||||
}),
|
||||
)}
|
||||
{customCategories.map((c, i) =>
|
||||
renderCustomModelRow("category", c, i),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
{!isSlim &&
|
||||
renderModelSection({
|
||||
title: t("omo.categories", { defaultValue: "Categories" }),
|
||||
isOpen: categoriesOpen,
|
||||
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
||||
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
||||
action: renderCustomAddButton(() => addCustomModel("category")),
|
||||
children: (
|
||||
<>
|
||||
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
||||
{customCategories.length > 0 && (
|
||||
<>
|
||||
{renderCustomDivider(
|
||||
t("omo.customCategories", {
|
||||
defaultValue: "Custom Categories",
|
||||
}),
|
||||
)}
|
||||
{customCategories.map((c, i) =>
|
||||
renderCustomModelRow("category", c, i),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderModelSection({
|
||||
title: t("omo.otherFieldsJson", {
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Save,
|
||||
Loader2,
|
||||
X,
|
||||
FolderInput,
|
||||
RotateCcw,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { OmoGlobalConfig } from "@/types/omo";
|
||||
import {
|
||||
OMO_DISABLEABLE_AGENTS,
|
||||
OMO_DISABLEABLE_MCPS,
|
||||
OMO_DISABLEABLE_HOOKS,
|
||||
OMO_DISABLEABLE_SKILLS,
|
||||
OMO_DEFAULT_SCHEMA_URL,
|
||||
OMO_SISYPHUS_AGENT_PLACEHOLDER,
|
||||
OMO_LSP_PLACEHOLDER,
|
||||
OMO_EXPERIMENTAL_PLACEHOLDER,
|
||||
OMO_BACKGROUND_TASK_PLACEHOLDER,
|
||||
OMO_BROWSER_AUTOMATION_PLACEHOLDER,
|
||||
OMO_CLAUDE_CODE_PLACEHOLDER,
|
||||
} from "@/types/omo";
|
||||
import {
|
||||
useOmoGlobalConfig,
|
||||
useSaveOmoGlobalConfig,
|
||||
useReadOmoLocalFile,
|
||||
} 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;
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
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 }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useOmoGlobalConfig();
|
||||
const saveMutation = useSaveOmoGlobalConfig();
|
||||
|
||||
const [schemaUrl, setSchemaUrl] = useState(OMO_DEFAULT_SCHEMA_URL);
|
||||
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 || OMO_DEFAULT_SCHEMA_URL);
|
||||
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: OMO_DISABLEABLE_AGENTS,
|
||||
},
|
||||
{
|
||||
key: "mcps",
|
||||
label: t("omo.disabledMcps", { defaultValue: "MCPs" }),
|
||||
values: disabledMcps,
|
||||
onChange: setDisabledMcps,
|
||||
placeholder: t("omo.disabledMcpsPlaceholder", {
|
||||
defaultValue: "Disabled MCPs",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_MCPS,
|
||||
},
|
||||
{
|
||||
key: "hooks",
|
||||
label: t("omo.disabledHooks", { defaultValue: "Hooks" }),
|
||||
values: disabledHooks,
|
||||
onChange: setDisabledHooks,
|
||||
placeholder: t("omo.disabledHooksPlaceholder", {
|
||||
defaultValue: "Disabled Hooks",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_HOOKS,
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: t("omo.disabledSkills", { defaultValue: "Skills" }),
|
||||
values: disabledSkills,
|
||||
onChange: setDisabledSkills,
|
||||
placeholder: t("omo.disabledSkillsPlaceholder", {
|
||||
defaultValue: "Disabled Skills",
|
||||
}),
|
||||
presets: OMO_DISABLEABLE_SKILLS,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const readLocalFile = useReadOmoLocalFile();
|
||||
|
||||
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={OMO_DEFAULT_SCHEMA_URL}
|
||||
className="text-sm h-8"
|
||||
/>
|
||||
</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.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.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>
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
// FlaskConical, // Hidden: stream check feature disabled
|
||||
Globe,
|
||||
Coins,
|
||||
Eye,
|
||||
@@ -87,15 +87,16 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
||||
}
|
||||
|
||||
export function ProviderAdvancedConfig({
|
||||
testConfig,
|
||||
testConfig: _testConfig, // Hidden: stream check feature disabled
|
||||
proxyConfig,
|
||||
pricingConfig,
|
||||
onTestConfigChange,
|
||||
onTestConfigChange: _onTestConfigChange, // Hidden: stream check feature disabled
|
||||
onProxyConfigChange,
|
||||
onPricingConfigChange,
|
||||
}: ProviderAdvancedConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||
// Hidden: stream check feature disabled
|
||||
// const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||
proxyConfig.enabled,
|
||||
);
|
||||
@@ -110,10 +111,10 @@ export function ProviderAdvancedConfig({
|
||||
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
|
||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||
|
||||
// 同步外部 testConfig.enabled 变化到展开状态
|
||||
useEffect(() => {
|
||||
setIsTestConfigOpen(testConfig.enabled);
|
||||
}, [testConfig.enabled]);
|
||||
// Hidden: stream check feature disabled
|
||||
// useEffect(() => {
|
||||
// setIsTestConfigOpen(testConfig.enabled);
|
||||
// }, [testConfig.enabled]);
|
||||
|
||||
// 同步外部 proxyConfig.enabled 变化到展开状态
|
||||
useEffect(() => {
|
||||
@@ -167,7 +168,7 @@ export function ProviderAdvancedConfig({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 模型测试配置 */}
|
||||
{/* Hidden: stream check feature disabled — model test config panel
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
@@ -343,6 +344,7 @@ export function ProviderAdvancedConfig({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{/* 代理配置 */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
|
||||
import type { PricingModelSourceOption } from "../ProviderAdvancedConfig";
|
||||
|
||||
// ── Default configs ──────────────────────────────────────────────────
|
||||
|
||||
export const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||
export const CODEX_DEFAULT_CONFIG = JSON.stringify(
|
||||
{ auth: {}, config: "" },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
export const GEMINI_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "",
|
||||
GEMINI_API_KEY: "",
|
||||
GEMINI_MODEL: "gemini-3-pro-preview",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export const OPENCODE_DEFAULT_NPM = "@ai-sdk/openai-compatible";
|
||||
export const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
npm: OPENCODE_DEFAULT_NPM,
|
||||
options: {
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
export const OPENCODE_KNOWN_OPTION_KEYS = [
|
||||
"baseURL",
|
||||
"apiKey",
|
||||
"headers",
|
||||
] as const;
|
||||
|
||||
export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify(
|
||||
{
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
// ── Pure functions ───────────────────────────────────────────────────
|
||||
|
||||
export function isKnownOpencodeOptionKey(key: string): boolean {
|
||||
return OPENCODE_KNOWN_OPTION_KEYS.includes(
|
||||
key as (typeof OPENCODE_KNOWN_OPTION_KEYS)[number],
|
||||
);
|
||||
}
|
||||
|
||||
export function parseOpencodeConfig(
|
||||
settingsConfig?: Record<string, unknown>,
|
||||
): OpenCodeProviderConfig {
|
||||
const normalize = (
|
||||
parsed: Partial<OpenCodeProviderConfig>,
|
||||
): OpenCodeProviderConfig => ({
|
||||
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
|
||||
options:
|
||||
parsed.options && typeof parsed.options === "object"
|
||||
? (parsed.options as OpenCodeProviderConfig["options"])
|
||||
: {},
|
||||
models:
|
||||
parsed.models && typeof parsed.models === "object"
|
||||
? (parsed.models as Record<string, OpenCodeModel>)
|
||||
: {},
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
|
||||
) as Partial<OpenCodeProviderConfig>;
|
||||
return normalize(parsed);
|
||||
} catch {
|
||||
return {
|
||||
npm: OPENCODE_DEFAULT_NPM,
|
||||
options: {},
|
||||
models: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpencodeConfigStrict(
|
||||
settingsConfig?: Record<string, unknown>,
|
||||
): OpenCodeProviderConfig {
|
||||
const parsed = JSON.parse(
|
||||
settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG,
|
||||
) as Partial<OpenCodeProviderConfig>;
|
||||
return {
|
||||
npm: parsed.npm || OPENCODE_DEFAULT_NPM,
|
||||
options:
|
||||
parsed.options && typeof parsed.options === "object"
|
||||
? (parsed.options as OpenCodeProviderConfig["options"])
|
||||
: {},
|
||||
models:
|
||||
parsed.models && typeof parsed.models === "object"
|
||||
? (parsed.models as Record<string, OpenCodeModel>)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function toOpencodeExtraOptions(
|
||||
options: OpenCodeProviderConfig["options"],
|
||||
): Record<string, string> {
|
||||
const extra: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(options || {})) {
|
||||
if (!isKnownOpencodeOptionKey(k)) {
|
||||
extra[k] = typeof v === "string" ? v : JSON.stringify(v);
|
||||
}
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
export { buildOmoProfilePreview } from "@/types/omo";
|
||||
|
||||
export const normalizePricingSource = (
|
||||
value?: string,
|
||||
): PricingModelSourceOption =>
|
||||
value === "request" || value === "response" ? value : "inherit";
|
||||
@@ -12,3 +12,7 @@ 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";
|
||||
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||
|
||||
@@ -188,12 +188,14 @@ export function useCodexCommonConfig({
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json(清空)
|
||||
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
configApi
|
||||
.setCommonConfigSnippet("codex", "")
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
||||
@@ -210,12 +212,14 @@ export function useCodexCommonConfig({
|
||||
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json
|
||||
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
configApi
|
||||
.setCommonConfigSnippet("codex", value)
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
// 若当前启用通用配置,需要替换为最新片段
|
||||
if (useCommonConfig) {
|
||||
|
||||
@@ -200,12 +200,14 @@ export function useCommonConfigSnippet({
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json(清空)
|
||||
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
configApi
|
||||
.setCommonConfigSnippet("claude", "")
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
const { updatedConfig } = updateCommonConfigSnippet(
|
||||
@@ -226,12 +228,14 @@ export function useCommonConfigSnippet({
|
||||
} else {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json
|
||||
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
configApi
|
||||
.setCommonConfigSnippet("claude", value)
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
||||
|
||||
@@ -322,12 +322,14 @@ export function useGeminiCommonConfig({
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
// 保存到 config.json(清空)
|
||||
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
configApi
|
||||
.setCommonConfigSnippet("gemini", "")
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
if (useCommonConfig) {
|
||||
const parsed = parseSnippetEnv(previousSnippet);
|
||||
@@ -349,12 +351,14 @@ export function useGeminiCommonConfig({
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
configApi
|
||||
.setCommonConfigSnippet("gemini", value)
|
||||
.catch((error: unknown) => {
|
||||
console.error("保存 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
||||
);
|
||||
});
|
||||
|
||||
// 若当前启用通用配置,需要替换为最新片段
|
||||
if (useCommonConfig) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
buildOmoSlimProfilePreview,
|
||||
buildOmoProfilePreview,
|
||||
} from "@/types/omo";
|
||||
|
||||
interface UseOmoDraftStateParams {
|
||||
initialOmoSettings: Record<string, unknown> | undefined;
|
||||
isEditMode: boolean;
|
||||
appId: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface OmoDraftState {
|
||||
omoAgents: Record<string, Record<string, unknown>>;
|
||||
setOmoAgents: React.Dispatch<
|
||||
React.SetStateAction<Record<string, Record<string, unknown>>>
|
||||
>;
|
||||
omoCategories: Record<string, Record<string, unknown>>;
|
||||
setOmoCategories: React.Dispatch<
|
||||
React.SetStateAction<Record<string, Record<string, unknown>>>
|
||||
>;
|
||||
omoOtherFieldsStr: string;
|
||||
setOmoOtherFieldsStr: React.Dispatch<React.SetStateAction<string>>;
|
||||
mergedOmoJsonPreview: string;
|
||||
resetOmoDraftState: () => void;
|
||||
}
|
||||
|
||||
export function useOmoDraftState({
|
||||
initialOmoSettings,
|
||||
category,
|
||||
}: UseOmoDraftStateParams): OmoDraftState {
|
||||
const isSlim = category === "omo-slim";
|
||||
|
||||
const [omoAgents, setOmoAgents] = useState<
|
||||
Record<string, Record<string, unknown>>
|
||||
>(
|
||||
() =>
|
||||
(initialOmoSettings?.agents as Record<string, Record<string, unknown>>) ||
|
||||
{},
|
||||
);
|
||||
const [omoCategories, setOmoCategories] = useState<
|
||||
Record<string, Record<string, unknown>>
|
||||
>(
|
||||
() =>
|
||||
(initialOmoSettings?.categories as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>) || {},
|
||||
);
|
||||
const [omoOtherFieldsStr, setOmoOtherFieldsStr] = useState(() => {
|
||||
const otherFields = initialOmoSettings?.otherFields;
|
||||
return otherFields ? JSON.stringify(otherFields, null, 2) : "";
|
||||
});
|
||||
|
||||
const mergedOmoJsonPreview = useMemo(() => {
|
||||
if (isSlim) {
|
||||
return JSON.stringify(
|
||||
buildOmoSlimProfilePreview(omoAgents, omoOtherFieldsStr),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
return JSON.stringify(
|
||||
buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [omoAgents, omoCategories, omoOtherFieldsStr, isSlim]);
|
||||
|
||||
const resetOmoDraftState = useCallback(() => {
|
||||
setOmoAgents({});
|
||||
setOmoCategories({});
|
||||
setOmoOtherFieldsStr("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
omoAgents,
|
||||
setOmoAgents,
|
||||
omoCategories,
|
||||
setOmoCategories,
|
||||
omoOtherFieldsStr,
|
||||
setOmoOtherFieldsStr,
|
||||
mergedOmoJsonPreview,
|
||||
resetOmoDraftState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { providersApi } from "@/lib/api";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
import type { OpenCodeProviderConfig } from "@/types";
|
||||
import { OPENCODE_PRESET_MODEL_VARIANTS } from "@/config/opencodeProviderPresets";
|
||||
import { parseOpencodeConfigStrict } from "../helpers/opencodeFormUtils";
|
||||
|
||||
interface UseOmoModelSourceParams {
|
||||
isOmoCategory: boolean;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
interface OmoModelBuild {
|
||||
options: Array<{ value: string; label: string }>;
|
||||
variantsMap: Record<string, string[]>;
|
||||
presetMetaMap: Record<
|
||||
string,
|
||||
{
|
||||
options?: Record<string, unknown>;
|
||||
limit?: { context?: number; output?: number };
|
||||
}
|
||||
>;
|
||||
parseFailedProviders: string[];
|
||||
usedFallbackSource: boolean;
|
||||
}
|
||||
|
||||
export interface OmoModelSourceResult {
|
||||
omoModelOptions: Array<{ value: string; label: string }>;
|
||||
omoModelVariantsMap: Record<string, string[]>;
|
||||
omoPresetMetaMap: Record<
|
||||
string,
|
||||
{
|
||||
options?: Record<string, unknown>;
|
||||
limit?: { context?: number; output?: number };
|
||||
}
|
||||
>;
|
||||
existingOpencodeKeys: string[];
|
||||
}
|
||||
|
||||
export function useOmoModelSource({
|
||||
isOmoCategory,
|
||||
providerId,
|
||||
}: UseOmoModelSourceParams): OmoModelSourceResult {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: opencodeProvidersData } = useProvidersQuery("opencode");
|
||||
const existingOpencodeKeys = useMemo(() => {
|
||||
if (!opencodeProvidersData?.providers) return [];
|
||||
return Object.keys(opencodeProvidersData.providers).filter(
|
||||
(k) => k !== providerId,
|
||||
);
|
||||
}, [opencodeProvidersData?.providers, providerId]);
|
||||
|
||||
const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState<
|
||||
string[] | null
|
||||
>(null);
|
||||
const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false);
|
||||
const lastOmoModelSourceWarningRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!isOmoCategory) {
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
setOmoLiveIdsLoadFailed(false);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
setOmoLiveIdsLoadFailed(false);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ids = await providersApi.getOpenCodeLiveProviderIds();
|
||||
if (active) {
|
||||
setEnabledOpencodeProviderIds(ids);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids",
|
||||
error,
|
||||
);
|
||||
if (active) {
|
||||
setOmoLiveIdsLoadFailed(true);
|
||||
setEnabledOpencodeProviderIds(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [isOmoCategory]);
|
||||
|
||||
const omoModelBuild = useMemo<OmoModelBuild>(() => {
|
||||
const empty: OmoModelBuild = {
|
||||
options: [],
|
||||
variantsMap: {},
|
||||
presetMetaMap: {},
|
||||
parseFailedProviders: [],
|
||||
usedFallbackSource: false,
|
||||
};
|
||||
if (!isOmoCategory) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const allProviders = opencodeProvidersData?.providers;
|
||||
if (!allProviders) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const shouldFilterByLive = !omoLiveIdsLoadFailed;
|
||||
if (shouldFilterByLive && enabledOpencodeProviderIds === null) {
|
||||
return empty;
|
||||
}
|
||||
const liveSet =
|
||||
shouldFilterByLive && enabledOpencodeProviderIds
|
||||
? new Set(enabledOpencodeProviderIds)
|
||||
: null;
|
||||
|
||||
const dedupedOptions = new Map<string, string>();
|
||||
const variantsMap: Record<string, string[]> = {};
|
||||
const presetMetaMap: Record<
|
||||
string,
|
||||
{
|
||||
options?: Record<string, unknown>;
|
||||
limit?: { context?: number; output?: number };
|
||||
}
|
||||
> = {};
|
||||
const parseFailedProviders: string[] = [];
|
||||
|
||||
for (const [providerKey, provider] of Object.entries(allProviders)) {
|
||||
if (provider.category === "omo" || provider.category === "omo-slim") {
|
||||
continue;
|
||||
}
|
||||
if (liveSet && !liveSet.has(providerKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsedConfig: OpenCodeProviderConfig;
|
||||
try {
|
||||
parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig);
|
||||
} catch (error) {
|
||||
parseFailedProviders.push(providerKey);
|
||||
console.warn(
|
||||
"[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings",
|
||||
{
|
||||
providerKey,
|
||||
error,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const [modelId, model] of Object.entries(
|
||||
parsedConfig.models || {},
|
||||
)) {
|
||||
const modelName =
|
||||
typeof model.name === "string" && model.name.trim()
|
||||
? model.name
|
||||
: modelId;
|
||||
const providerDisplayName =
|
||||
typeof provider.name === "string" && provider.name.trim()
|
||||
? provider.name
|
||||
: providerKey;
|
||||
const value = `${providerKey}/${modelId}`;
|
||||
const label = `${providerDisplayName} / ${modelName} (${modelId})`;
|
||||
if (!dedupedOptions.has(value)) {
|
||||
dedupedOptions.set(value, label);
|
||||
}
|
||||
|
||||
const rawVariants = model.variants;
|
||||
if (
|
||||
rawVariants &&
|
||||
typeof rawVariants === "object" &&
|
||||
!Array.isArray(rawVariants)
|
||||
) {
|
||||
const variantKeys = Object.keys(rawVariants).filter(Boolean);
|
||||
if (variantKeys.length > 0) {
|
||||
variantsMap[value] = variantKeys;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preset fallback: for models without config-defined variants,
|
||||
// check if the npm package has preset variant definitions.
|
||||
// Also collect preset metadata (options, limit) for enrichment.
|
||||
const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm];
|
||||
if (presetModels) {
|
||||
for (const modelId of Object.keys(parsedConfig.models || {})) {
|
||||
const fullKey = `${providerKey}/${modelId}`;
|
||||
const preset = presetModels.find((p) => p.id === modelId);
|
||||
if (!preset) continue;
|
||||
|
||||
// Variant fallback
|
||||
if (!variantsMap[fullKey] && preset.variants) {
|
||||
const presetKeys = Object.keys(preset.variants).filter(Boolean);
|
||||
if (presetKeys.length > 0) {
|
||||
variantsMap[fullKey] = presetKeys;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect preset metadata for model enrichment
|
||||
const meta: (typeof presetMetaMap)[string] = {};
|
||||
if (preset.options) meta.options = preset.options;
|
||||
if (preset.contextLimit || preset.outputLimit) {
|
||||
meta.limit = {};
|
||||
if (preset.contextLimit) meta.limit.context = preset.contextLimit;
|
||||
if (preset.outputLimit) meta.limit.output = preset.outputLimit;
|
||||
}
|
||||
if (Object.keys(meta).length > 0) {
|
||||
presetMetaMap[fullKey] = meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
options: Array.from(dedupedOptions.entries())
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "zh-CN")),
|
||||
variantsMap,
|
||||
presetMetaMap,
|
||||
parseFailedProviders,
|
||||
usedFallbackSource: omoLiveIdsLoadFailed,
|
||||
};
|
||||
}, [
|
||||
isOmoCategory,
|
||||
opencodeProvidersData?.providers,
|
||||
enabledOpencodeProviderIds,
|
||||
omoLiveIdsLoadFailed,
|
||||
]);
|
||||
|
||||
// Warning toast for parse failures / fallback
|
||||
useEffect(() => {
|
||||
if (!isOmoCategory) return;
|
||||
const failed = omoModelBuild.parseFailedProviders;
|
||||
const fallback = omoModelBuild.usedFallbackSource;
|
||||
if (failed.length === 0 && !fallback) return;
|
||||
|
||||
const signature = `${fallback ? "fallback:" : ""}${failed
|
||||
.slice()
|
||||
.sort()
|
||||
.join(",")}`;
|
||||
if (lastOmoModelSourceWarningRef.current === signature) return;
|
||||
lastOmoModelSourceWarningRef.current = signature;
|
||||
|
||||
if (failed.length > 0) {
|
||||
toast.warning(
|
||||
t("omo.modelSourcePartialWarning", {
|
||||
count: failed.length,
|
||||
defaultValue:
|
||||
"Some provider model configs are invalid and were skipped.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (fallback) {
|
||||
toast.warning(
|
||||
t("omo.modelSourceFallbackWarning", {
|
||||
defaultValue:
|
||||
"Failed to load live provider state. Falling back to configured providers.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isOmoCategory,
|
||||
omoModelBuild.parseFailedProviders,
|
||||
omoModelBuild.usedFallbackSource,
|
||||
t,
|
||||
]);
|
||||
|
||||
return {
|
||||
omoModelOptions: omoModelBuild.options,
|
||||
omoModelVariantsMap: omoModelBuild.variantsMap,
|
||||
omoPresetMetaMap: omoModelBuild.presetMetaMap,
|
||||
existingOpencodeKeys,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { OpenClawModel } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
import { OPENCLAW_DEFAULT_CONFIG } from "../helpers/opencodeFormUtils";
|
||||
|
||||
interface UseOpenclawFormStateParams {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
appId: AppId;
|
||||
providerId?: string;
|
||||
onSettingsConfigChange: (config: string) => void;
|
||||
getSettingsConfig: () => string;
|
||||
}
|
||||
|
||||
export interface OpenclawFormState {
|
||||
openclawProviderKey: string;
|
||||
setOpenclawProviderKey: (key: string) => void;
|
||||
openclawBaseUrl: string;
|
||||
openclawApiKey: string;
|
||||
openclawApi: string;
|
||||
openclawModels: OpenClawModel[];
|
||||
existingOpenclawKeys: string[];
|
||||
handleOpenclawBaseUrlChange: (baseUrl: string) => void;
|
||||
handleOpenclawApiKeyChange: (apiKey: string) => void;
|
||||
handleOpenclawApiChange: (api: string) => void;
|
||||
handleOpenclawModelsChange: (models: OpenClawModel[]) => void;
|
||||
resetOpenclawState: (config?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
api?: string;
|
||||
models?: OpenClawModel[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function parseOpenclawField<T>(
|
||||
initialData: UseOpenclawFormStateParams["initialData"],
|
||||
field: string,
|
||||
fallback: T,
|
||||
): T {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
initialData?.settingsConfig
|
||||
? JSON.stringify(initialData.settingsConfig)
|
||||
: OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
return (config[field] as T) || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenclawFormState({
|
||||
initialData,
|
||||
appId,
|
||||
providerId,
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig,
|
||||
}: UseOpenclawFormStateParams): OpenclawFormState {
|
||||
// Query existing providers for duplicate key checking
|
||||
const { data: openclawProvidersData } = useProvidersQuery("openclaw");
|
||||
const existingOpenclawKeys = useMemo(() => {
|
||||
if (!openclawProvidersData?.providers) return [];
|
||||
return Object.keys(openclawProvidersData.providers).filter(
|
||||
(k) => k !== providerId,
|
||||
);
|
||||
}, [openclawProvidersData?.providers, providerId]);
|
||||
|
||||
const [openclawProviderKey, setOpenclawProviderKey] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "";
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
const [openclawBaseUrl, setOpenclawBaseUrl] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "";
|
||||
return parseOpenclawField(initialData, "baseUrl", "");
|
||||
});
|
||||
|
||||
const [openclawApiKey, setOpenclawApiKey] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "";
|
||||
return parseOpenclawField(initialData, "apiKey", "");
|
||||
});
|
||||
|
||||
const [openclawApi, setOpenclawApi] = useState<string>(() => {
|
||||
if (appId !== "openclaw") return "openai-completions";
|
||||
return parseOpenclawField(initialData, "api", "openai-completions");
|
||||
});
|
||||
|
||||
const [openclawModels, setOpenclawModels] = useState<OpenClawModel[]>(() => {
|
||||
if (appId !== "openclaw") return [];
|
||||
return parseOpenclawField<OpenClawModel[]>(initialData, "models", []);
|
||||
});
|
||||
|
||||
const updateOpenclawConfig = useCallback(
|
||||
(updater: (config: Record<string, any>) => void) => {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
getSettingsConfig() || OPENCLAW_DEFAULT_CONFIG,
|
||||
);
|
||||
updater(config);
|
||||
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[getSettingsConfig, onSettingsConfigChange],
|
||||
);
|
||||
|
||||
const handleOpenclawBaseUrlChange = useCallback(
|
||||
(baseUrl: string) => {
|
||||
setOpenclawBaseUrl(baseUrl);
|
||||
updateOpenclawConfig((config) => {
|
||||
config.baseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
});
|
||||
},
|
||||
[updateOpenclawConfig],
|
||||
);
|
||||
|
||||
const handleOpenclawApiKeyChange = useCallback(
|
||||
(apiKey: string) => {
|
||||
setOpenclawApiKey(apiKey);
|
||||
updateOpenclawConfig((config) => {
|
||||
config.apiKey = apiKey;
|
||||
});
|
||||
},
|
||||
[updateOpenclawConfig],
|
||||
);
|
||||
|
||||
const handleOpenclawApiChange = useCallback(
|
||||
(api: string) => {
|
||||
setOpenclawApi(api);
|
||||
updateOpenclawConfig((config) => {
|
||||
config.api = api;
|
||||
});
|
||||
},
|
||||
[updateOpenclawConfig],
|
||||
);
|
||||
|
||||
const handleOpenclawModelsChange = useCallback(
|
||||
(models: OpenClawModel[]) => {
|
||||
setOpenclawModels(models);
|
||||
updateOpenclawConfig((config) => {
|
||||
config.models = models;
|
||||
});
|
||||
},
|
||||
[updateOpenclawConfig],
|
||||
);
|
||||
|
||||
const resetOpenclawState = useCallback(
|
||||
(config?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
api?: string;
|
||||
models?: OpenClawModel[];
|
||||
}) => {
|
||||
setOpenclawProviderKey("");
|
||||
setOpenclawBaseUrl(config?.baseUrl || "");
|
||||
setOpenclawApiKey(config?.apiKey || "");
|
||||
setOpenclawApi(config?.api || "openai-completions");
|
||||
setOpenclawModels(config?.models || []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
openclawProviderKey,
|
||||
setOpenclawProviderKey,
|
||||
openclawBaseUrl,
|
||||
openclawApiKey,
|
||||
openclawApi,
|
||||
openclawModels,
|
||||
existingOpenclawKeys,
|
||||
handleOpenclawBaseUrlChange,
|
||||
handleOpenclawApiKeyChange,
|
||||
handleOpenclawApiChange,
|
||||
handleOpenclawModelsChange,
|
||||
resetOpenclawState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types";
|
||||
import {
|
||||
OPENCODE_DEFAULT_NPM,
|
||||
OPENCODE_DEFAULT_CONFIG,
|
||||
isKnownOpencodeOptionKey,
|
||||
parseOpencodeConfig,
|
||||
toOpencodeExtraOptions,
|
||||
} from "../helpers/opencodeFormUtils";
|
||||
|
||||
interface UseOpencodeFormStateParams {
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
};
|
||||
appId: string;
|
||||
providerId?: string;
|
||||
onSettingsConfigChange: (config: string) => void;
|
||||
getSettingsConfig: () => string;
|
||||
}
|
||||
|
||||
export interface OpencodeFormState {
|
||||
opencodeProviderKey: string;
|
||||
setOpencodeProviderKey: (key: string) => void;
|
||||
opencodeNpm: string;
|
||||
opencodeApiKey: string;
|
||||
opencodeBaseUrl: string;
|
||||
opencodeModels: Record<string, OpenCodeModel>;
|
||||
opencodeExtraOptions: Record<string, string>;
|
||||
handleOpencodeNpmChange: (npm: string) => void;
|
||||
handleOpencodeApiKeyChange: (apiKey: string) => void;
|
||||
handleOpencodeBaseUrlChange: (baseUrl: string) => void;
|
||||
handleOpencodeModelsChange: (models: Record<string, OpenCodeModel>) => void;
|
||||
handleOpencodeExtraOptionsChange: (options: Record<string, string>) => void;
|
||||
resetOpencodeState: (config?: OpenCodeProviderConfig) => void;
|
||||
}
|
||||
|
||||
export function useOpencodeFormState({
|
||||
initialData,
|
||||
appId,
|
||||
providerId,
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig,
|
||||
}: UseOpencodeFormStateParams): OpencodeFormState {
|
||||
const initialOpencodeConfig =
|
||||
appId === "opencode"
|
||||
? parseOpencodeConfig(initialData?.settingsConfig)
|
||||
: null;
|
||||
const initialOpencodeOptions = initialOpencodeConfig?.options || {};
|
||||
|
||||
const [opencodeProviderKey, setOpencodeProviderKey] = useState<string>(() => {
|
||||
if (appId !== "opencode") return "";
|
||||
return providerId || "";
|
||||
});
|
||||
|
||||
const [opencodeNpm, setOpencodeNpm] = useState<string>(() => {
|
||||
if (appId !== "opencode") return OPENCODE_DEFAULT_NPM;
|
||||
return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM;
|
||||
});
|
||||
|
||||
const [opencodeApiKey, setOpencodeApiKey] = useState<string>(() => {
|
||||
if (appId !== "opencode") return "";
|
||||
const value = initialOpencodeOptions.apiKey;
|
||||
return typeof value === "string" ? value : "";
|
||||
});
|
||||
|
||||
const [opencodeBaseUrl, setOpencodeBaseUrl] = useState<string>(() => {
|
||||
if (appId !== "opencode") return "";
|
||||
const value = initialOpencodeOptions.baseURL;
|
||||
return typeof value === "string" ? value : "";
|
||||
});
|
||||
|
||||
const [opencodeModels, setOpencodeModels] = useState<
|
||||
Record<string, OpenCodeModel>
|
||||
>(() => {
|
||||
if (appId !== "opencode") return {};
|
||||
return initialOpencodeConfig?.models || {};
|
||||
});
|
||||
|
||||
const [opencodeExtraOptions, setOpencodeExtraOptions] = useState<
|
||||
Record<string, string>
|
||||
>(() => {
|
||||
if (appId !== "opencode") return {};
|
||||
return toOpencodeExtraOptions(initialOpencodeOptions);
|
||||
});
|
||||
|
||||
const updateOpencodeSettings = useCallback(
|
||||
(updater: (config: Record<string, any>) => void) => {
|
||||
try {
|
||||
const config = JSON.parse(
|
||||
getSettingsConfig() || OPENCODE_DEFAULT_CONFIG,
|
||||
) as Record<string, any>;
|
||||
updater(config);
|
||||
onSettingsConfigChange(JSON.stringify(config, null, 2));
|
||||
} catch {}
|
||||
},
|
||||
[getSettingsConfig, onSettingsConfigChange],
|
||||
);
|
||||
|
||||
const handleOpencodeNpmChange = useCallback(
|
||||
(npm: string) => {
|
||||
setOpencodeNpm(npm);
|
||||
updateOpencodeSettings((config) => {
|
||||
config.npm = npm;
|
||||
});
|
||||
},
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeApiKeyChange = useCallback(
|
||||
(apiKey: string) => {
|
||||
setOpencodeApiKey(apiKey);
|
||||
updateOpencodeSettings((config) => {
|
||||
if (!config.options) config.options = {};
|
||||
config.options.apiKey = apiKey;
|
||||
});
|
||||
},
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeBaseUrlChange = useCallback(
|
||||
(baseUrl: string) => {
|
||||
setOpencodeBaseUrl(baseUrl);
|
||||
updateOpencodeSettings((config) => {
|
||||
if (!config.options) config.options = {};
|
||||
config.options.baseURL = baseUrl.trim().replace(/\/+$/, "");
|
||||
});
|
||||
},
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeModelsChange = useCallback(
|
||||
(models: Record<string, OpenCodeModel>) => {
|
||||
setOpencodeModels(models);
|
||||
updateOpencodeSettings((config) => {
|
||||
config.models = models;
|
||||
});
|
||||
},
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const handleOpencodeExtraOptionsChange = useCallback(
|
||||
(options: Record<string, string>) => {
|
||||
setOpencodeExtraOptions(options);
|
||||
updateOpencodeSettings((config) => {
|
||||
if (!config.options) config.options = {};
|
||||
|
||||
for (const k of Object.keys(config.options)) {
|
||||
if (!isKnownOpencodeOptionKey(k)) {
|
||||
delete config.options[k];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
const trimmedKey = k.trim();
|
||||
if (trimmedKey && !trimmedKey.startsWith("option-")) {
|
||||
try {
|
||||
config.options[trimmedKey] = JSON.parse(v);
|
||||
} catch {
|
||||
config.options[trimmedKey] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
[updateOpencodeSettings],
|
||||
);
|
||||
|
||||
const resetOpencodeState = useCallback((config?: OpenCodeProviderConfig) => {
|
||||
setOpencodeProviderKey("");
|
||||
setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM);
|
||||
setOpencodeBaseUrl(config?.options?.baseURL || "");
|
||||
setOpencodeApiKey(config?.options?.apiKey || "");
|
||||
setOpencodeModels(config?.models || {});
|
||||
setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
opencodeProviderKey,
|
||||
setOpencodeProviderKey,
|
||||
opencodeNpm,
|
||||
opencodeApiKey,
|
||||
opencodeBaseUrl,
|
||||
opencodeModels,
|
||||
opencodeExtraOptions,
|
||||
handleOpencodeNpmChange,
|
||||
handleOpencodeApiKeyChange,
|
||||
handleOpencodeBaseUrlChange,
|
||||
handleOpencodeModelsChange,
|
||||
handleOpencodeExtraOptionsChange,
|
||||
resetOpencodeState,
|
||||
};
|
||||
}
|
||||
@@ -7,11 +7,14 @@ 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";
|
||||
@@ -25,8 +28,21 @@ import {
|
||||
} from "@/lib/query/proxy";
|
||||
import type { ProxyStatus } from "@/types/proxy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
export function ProxyPanel() {
|
||||
interface ProxyPanelProps {
|
||||
enableLocalProxy: boolean;
|
||||
onEnableLocalProxyChange: (checked: boolean) => void;
|
||||
onToggleProxy: (checked: boolean) => Promise<void>;
|
||||
isProxyPending: boolean;
|
||||
}
|
||||
|
||||
export function ProxyPanel({
|
||||
enableLocalProxy,
|
||||
onEnableLocalProxyChange,
|
||||
onToggleProxy,
|
||||
isProxyPending,
|
||||
}: ProxyPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { status, isRunning } = useProxyStatus();
|
||||
|
||||
@@ -183,9 +199,98 @@ export function ProxyPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-6">
|
||||
<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 */}
|
||||
{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">
|
||||
@@ -263,41 +368,7 @@ export function ProxyPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 应用接管开关 */}
|
||||
<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>
|
||||
|
||||
{/* 日志记录开关 */}
|
||||
{/* [5] Logging toggle */}
|
||||
<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">
|
||||
@@ -320,7 +391,7 @@ export function ProxyPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 供应商队列 - 按应用类型分组展示 */}
|
||||
{/* [6] Provider queues */}
|
||||
{(claudeQueue.length > 0 ||
|
||||
codexQueue.length > 0 ||
|
||||
geminiQueue.length > 0) && (
|
||||
@@ -332,7 +403,6 @@ export function ProxyPanel() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Claude 队列 */}
|
||||
{claudeQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="claude"
|
||||
@@ -345,7 +415,6 @@ export function ProxyPanel() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex 队列 */}
|
||||
{codexQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="codex"
|
||||
@@ -358,7 +427,6 @@ export function ProxyPanel() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gemini 队列 */}
|
||||
{geminiQueue.length > 0 && (
|
||||
<ProviderQueueGroup
|
||||
appType="gemini"
|
||||
@@ -374,6 +442,7 @@ export function ProxyPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* [7] Stats cards */}
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
@@ -408,10 +477,7 @@ export function ProxyPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* 空白区域避免冲突 */}
|
||||
<div className="h-4"></div>
|
||||
|
||||
{/* 基础设置 - 监听地址/端口 */}
|
||||
{/* [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">
|
||||
@@ -499,7 +565,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" />
|
||||
@@ -511,7 +577,7 @@ export function ProxyPanel() {
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxy.panel.stoppedDescription", {
|
||||
defaultValue: "使用右上角开关即可启动服务",
|
||||
defaultValue: "使用上方开关即可启动服务",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -46,9 +46,15 @@ import {
|
||||
getSessionKey,
|
||||
} from "./utils";
|
||||
|
||||
type ProviderFilter = "all" | "codex" | "claude";
|
||||
type ProviderFilter =
|
||||
| "all"
|
||||
| "codex"
|
||||
| "claude"
|
||||
| "opencode"
|
||||
| "openclaw"
|
||||
| "gemini";
|
||||
|
||||
export function SessionManagerPage() {
|
||||
export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, refetch } = useSessionsQuery();
|
||||
const sessions = data ?? [];
|
||||
@@ -63,7 +69,9 @@ export function SessionManagerPage() {
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
|
||||
const [providerFilter, setProviderFilter] = useState<ProviderFilter>(
|
||||
appId as ProviderFilter,
|
||||
);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
|
||||
// 使用 FlexSearch 全文搜索
|
||||
@@ -265,9 +273,7 @@ export function SessionManagerPage() {
|
||||
icon={
|
||||
providerFilter === "all"
|
||||
? "apps"
|
||||
: providerFilter === "codex"
|
||||
? "openai"
|
||||
: "claude"
|
||||
: getProviderIconName(providerFilter)
|
||||
}
|
||||
name={providerFilter}
|
||||
size={14}
|
||||
@@ -309,6 +315,36 @@ export function SessionManagerPage() {
|
||||
<span>Claude Code</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="opencode">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="opencode"
|
||||
name="opencode"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenCode</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="openclaw">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="openclaw"
|
||||
name="openclaw"
|
||||
size={14}
|
||||
/>
|
||||
<span>OpenClaw</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
icon="gemini"
|
||||
name="gemini"
|
||||
size={14}
|
||||
/>
|
||||
<span>Gemini CLI</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export const getProviderLabel = (
|
||||
export const getProviderIconName = (providerId: string) => {
|
||||
if (providerId === "codex") return "openai";
|
||||
if (providerId === "claude") return "claude";
|
||||
if (providerId === "opencode") return "opencode";
|
||||
if (providerId === "openclaw") return "openclaw";
|
||||
return providerId;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
@@ -20,6 +27,7 @@ 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;
|
||||
@@ -30,8 +38,48 @@ interface ToolVersion {
|
||||
version: string | null;
|
||||
latest_version: string | null;
|
||||
error: string | null;
|
||||
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
|
||||
wsl_distro: string | null;
|
||||
}
|
||||
|
||||
const TOOL_NAMES = ["claude", "codex", "gemini", "opencode"] as const;
|
||||
type ToolName = (typeof TOOL_NAMES)[number];
|
||||
|
||||
type WslShellPreference = {
|
||||
wslShell?: string | null;
|
||||
wslShellFlag?: string | null;
|
||||
};
|
||||
|
||||
const WSL_SHELL_OPTIONS = ["sh", "bash", "zsh", "fish", "dash"] as const;
|
||||
// UI-friendly order: login shell first.
|
||||
const WSL_SHELL_FLAG_OPTIONS = ["-lic", "-lc", "-c"] as const;
|
||||
|
||||
const ENV_BADGE_CONFIG: Record<
|
||||
string,
|
||||
{ labelKey: string; className: string }
|
||||
> = {
|
||||
wsl: {
|
||||
labelKey: "settings.envBadge.wsl",
|
||||
className:
|
||||
"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||
},
|
||||
windows: {
|
||||
labelKey: "settings.envBadge.windows",
|
||||
className:
|
||||
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
},
|
||||
macos: {
|
||||
labelKey: "settings.envBadge.macos",
|
||||
className:
|
||||
"bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20",
|
||||
},
|
||||
linux: {
|
||||
labelKey: "settings.envBadge.linux",
|
||||
className:
|
||||
"bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
# Codex
|
||||
@@ -59,30 +107,104 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
isChecking,
|
||||
} = useUpdate();
|
||||
|
||||
const loadToolVersions = useCallback(async () => {
|
||||
const [wslShellByTool, setWslShellByTool] = useState<
|
||||
Record<string, WslShellPreference>
|
||||
>({});
|
||||
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
|
||||
|
||||
const refreshToolVersions = useCallback(
|
||||
async (
|
||||
toolNames: ToolName[],
|
||||
wslOverrides?: Record<string, WslShellPreference>,
|
||||
) => {
|
||||
if (toolNames.length === 0) return;
|
||||
|
||||
// 单工具刷新使用统一后端入口(get_tool_versions)并带工具过滤。
|
||||
setLoadingTools((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const name of toolNames) next[name] = true;
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const updated = await settingsApi.getToolVersions(
|
||||
toolNames,
|
||||
wslOverrides,
|
||||
);
|
||||
|
||||
setToolVersions((prev) => {
|
||||
if (prev.length === 0) return updated;
|
||||
const byName = new Map(updated.map((t) => [t.name, t]));
|
||||
const merged = prev.map((t) => byName.get(t.name) ?? t);
|
||||
const existing = new Set(prev.map((t) => t.name));
|
||||
for (const u of updated) {
|
||||
if (!existing.has(u.name)) merged.push(u);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AboutSection] Failed to refresh tools", error);
|
||||
} finally {
|
||||
setLoadingTools((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const name of toolNames) next[name] = false;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadAllToolVersions = useCallback(async () => {
|
||||
setIsLoadingTools(true);
|
||||
try {
|
||||
const tools = await settingsApi.getToolVersions();
|
||||
setToolVersions(tools);
|
||||
// Respect current UI overrides (shell / flag) when doing a full refresh.
|
||||
const versions = await settingsApi.getToolVersions(
|
||||
[...TOOL_NAMES],
|
||||
wslShellByTool,
|
||||
);
|
||||
setToolVersions(versions);
|
||||
} catch (error) {
|
||||
console.error("[AboutSection] Failed to load tool versions", error);
|
||||
} finally {
|
||||
setIsLoadingTools(false);
|
||||
}
|
||||
}, []);
|
||||
}, [wslShellByTool]);
|
||||
|
||||
const handleToolShellChange = async (toolName: ToolName, value: string) => {
|
||||
const wslShell = value === "auto" ? null : value;
|
||||
const nextPref: WslShellPreference = {
|
||||
...(wslShellByTool[toolName] ?? {}),
|
||||
wslShell,
|
||||
};
|
||||
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
|
||||
await refreshToolVersions([toolName], { [toolName]: nextPref });
|
||||
};
|
||||
|
||||
const handleToolShellFlagChange = async (
|
||||
toolName: ToolName,
|
||||
value: string,
|
||||
) => {
|
||||
const wslShellFlag = value === "auto" ? null : value;
|
||||
const nextPref: WslShellPreference = {
|
||||
...(wslShellByTool[toolName] ?? {}),
|
||||
wslShellFlag,
|
||||
};
|
||||
setWslShellByTool((prev) => ({ ...prev, [toolName]: nextPref }));
|
||||
await refreshToolVersions([toolName], { [toolName]: nextPref });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [appVersion, tools] = await Promise.all([
|
||||
const [appVersion] = await Promise.all([
|
||||
getVersion(),
|
||||
settingsApi.getToolVersions(),
|
||||
...(isWindows() ? [] : [loadAllToolVersions()]),
|
||||
]);
|
||||
|
||||
if (active) {
|
||||
setVersion(appVersion);
|
||||
setToolVersions(tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AboutSection] Failed to load info", error);
|
||||
@@ -92,7 +214,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoadingVersion(false);
|
||||
setIsLoadingTools(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -101,6 +222,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// Mount-only: loadAllToolVersions is intentionally excluded to avoid
|
||||
// re-fetching all tools whenever wslShellByTool changes. Single-tool
|
||||
// refreshes are handled by refreshToolVersions in the shell/flag handlers.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
|
||||
@@ -299,109 +424,173 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<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={loadToolVersions}
|
||||
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">
|
||||
{["claude", "codex", "gemini", "opencode"].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>
|
||||
</div>
|
||||
{isLoadingTools ? (
|
||||
<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>
|
||||
{!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>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCopyInstallCommands}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => loadAllToolVersions()}
|
||||
disabled={isLoadingTools}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("common.copy")}
|
||||
<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>
|
||||
<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 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>
|
||||
</div>
|
||||
</motion.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.section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, RotateCcw, Check, X, Download, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useBackupManager } from "@/hooks/useBackupManager";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
||||
interface BackupListSectionProps {
|
||||
backupIntervalHours?: number;
|
||||
backupRetainCount?: number;
|
||||
onSettingsChange: (updates: {
|
||||
backupIntervalHours?: number;
|
||||
backupRetainCount?: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatBackupDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse display name from backup filename */
|
||||
function getDisplayName(filename: string): string {
|
||||
// Try to parse db_backup_YYYYMMDD_HHMMSS format
|
||||
const match = filename.match(
|
||||
/^db_backup_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})(?:_\d+)?\.db$/,
|
||||
);
|
||||
if (match) {
|
||||
const [, y, m, d, hh, mm, ss] = match;
|
||||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
|
||||
}
|
||||
// Otherwise show filename without .db suffix
|
||||
return filename.replace(/\.db$/, "");
|
||||
}
|
||||
|
||||
export function BackupListSection({
|
||||
backupIntervalHours,
|
||||
backupRetainCount,
|
||||
onSettingsChange,
|
||||
}: BackupListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
backups,
|
||||
isLoading,
|
||||
create,
|
||||
isCreating,
|
||||
restore,
|
||||
isRestoring,
|
||||
rename,
|
||||
isRenaming,
|
||||
remove,
|
||||
isDeleting,
|
||||
} = useBackupManager();
|
||||
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
|
||||
const [deleteFilename, setDeleteFilename] = useState<string | null>(null);
|
||||
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!confirmFilename) return;
|
||||
try {
|
||||
const safetyId = await restore(confirmFilename);
|
||||
setConfirmFilename(null);
|
||||
toast.success(
|
||||
t("settings.backupManager.restoreSuccess", {
|
||||
defaultValue: "Restore successful! Safety backup created",
|
||||
}),
|
||||
{
|
||||
description: safetyId
|
||||
? `${t("settings.backupManager.safetyBackupId", { defaultValue: "Safety Backup ID" })}: ${safetyId}`
|
||||
: undefined,
|
||||
duration: 6000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("settings.backupManager.restoreFailed", {
|
||||
defaultValue: "Restore failed",
|
||||
});
|
||||
toast.error(detail);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRename = (filename: string) => {
|
||||
setEditingFilename(filename);
|
||||
setEditValue(getDisplayName(filename));
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setEditingFilename(null);
|
||||
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 {
|
||||
await rename({ oldFilename: editingFilename, newName: editValue.trim() });
|
||||
setEditingFilename(null);
|
||||
setEditValue("");
|
||||
toast.success(
|
||||
t("settings.backupManager.renameSuccess", {
|
||||
defaultValue: "Backup renamed",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("settings.backupManager.renameFailed", {
|
||||
defaultValue: "Rename failed",
|
||||
});
|
||||
toast.error(detail);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalValue = String(backupIntervalHours ?? 24);
|
||||
const retainValue = String(backupRetainCount ?? 10);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Backup policy settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
{t("settings.backupManager.intervalLabel", {
|
||||
defaultValue: "Auto-backup Interval",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={intervalValue}
|
||||
onValueChange={(v) =>
|
||||
onSettingsChange({ backupIntervalHours: Number(v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">
|
||||
{t("settings.backupManager.intervalDisabled", {
|
||||
defaultValue: "Disabled",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="6">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 6,
|
||||
defaultValue: "6 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="12">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 12,
|
||||
defaultValue: "12 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="24">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 24,
|
||||
defaultValue: "24 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="48">
|
||||
{t("settings.backupManager.intervalHours", {
|
||||
hours: 48,
|
||||
defaultValue: "48 hours",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="168">
|
||||
{t("settings.backupManager.intervalDays", {
|
||||
days: 7,
|
||||
defaultValue: "7 days",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
{t("settings.backupManager.retainLabel", {
|
||||
defaultValue: "Backup Retention",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={retainValue}
|
||||
onValueChange={(v) =>
|
||||
onSettingsChange({ backupRetainCount: Number(v) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[3, 5, 10, 15, 20, 30, 50].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground py-2">Loading...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
{t("settings.backupManager.empty", {
|
||||
defaultValue: "No backups yet",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.filename}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingFilename === backup.filename ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleConfirmRename();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
placeholder={t(
|
||||
"settings.backupManager.namePlaceholder",
|
||||
{ defaultValue: "Enter new name" },
|
||||
)}
|
||||
autoFocus
|
||||
disabled={isRenaming}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming || !editValue.trim()}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={handleCancelRename}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-mono text-xs truncate">
|
||||
{getDisplayName(backup.filename)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBackupDate(backup.createdAt)} ·{" "}
|
||||
{formatBytes(backup.sizeBytes)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editingFilename !== backup.filename && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleStartRename(backup.filename)}
|
||||
disabled={isRestoring || isRenaming || isDeleting}
|
||||
title={t("settings.backupManager.rename", {
|
||||
defaultValue: "Rename",
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteFilename(backup.filename)}
|
||||
disabled={isRestoring || isDeleting}
|
||||
title={t("settings.backupManager.delete", {
|
||||
defaultValue: "Delete",
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={isRestoring || isDeleting}
|
||||
onClick={() => setConfirmFilename(backup.filename)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
{isRestoring
|
||||
? t("settings.backupManager.restoring", {
|
||||
defaultValue: "Restoring...",
|
||||
})
|
||||
: t("settings.backupManager.restore", {
|
||||
defaultValue: "Restore",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={!!confirmFilename}
|
||||
onOpenChange={(open) => !open && setConfirmFilename(null)}
|
||||
>
|
||||
<DialogContent className="max-w-md" zIndex="alert">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("settings.backupManager.confirmTitle", {
|
||||
defaultValue: "Confirm Restore",
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.backupManager.confirmMessage", {
|
||||
defaultValue:
|
||||
"Restoring this backup will overwrite the current database. A safety backup will be created first.",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmFilename(null)}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{t("common.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button onClick={handleRestore} disabled={isRestoring}>
|
||||
{isRestoring
|
||||
? t("settings.backupManager.restoring", {
|
||||
defaultValue: "Restoring...",
|
||||
})
|
||||
: t("settings.backupManager.restore", {
|
||||
defaultValue: "Restore",
|
||||
})}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useState } from "react";
|
||||
import { Server, Activity, Zap, Globe } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
|
||||
interface ProxyTabContentProps {
|
||||
settings: SettingsFormState;
|
||||
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ProxyTabContent({
|
||||
settings,
|
||||
onAutoSave,
|
||||
}: ProxyTabContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showProxyConfirm, setShowProxyConfirm] = useState(false);
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
startProxyServer,
|
||||
stopWithRestore,
|
||||
isPending: isProxyPending,
|
||||
} = useProxyStatus();
|
||||
|
||||
const handleToggleProxy = async (checked: boolean) => {
|
||||
try {
|
||||
if (!checked) {
|
||||
await stopWithRestore();
|
||||
} else if (!settings?.proxyConfirmed) {
|
||||
setShowProxyConfirm(true);
|
||||
} else {
|
||||
await startProxyServer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Toggle proxy failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxyConfirm = async () => {
|
||||
setShowProxyConfirm(false);
|
||||
try {
|
||||
await onAutoSave({ proxyConfirmed: true });
|
||||
await startProxyServer();
|
||||
} catch (error) {
|
||||
console.error("Proxy confirm failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
|
||||
{/* Local Proxy */}
|
||||
<AccordionItem
|
||||
value="proxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="gap-1.5 h-6 ml-auto mr-2"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ProxyPanel
|
||||
enableLocalProxy={settings?.enableLocalProxy ?? false}
|
||||
onEnableLocalProxyChange={(checked) =>
|
||||
onAutoSave({ enableLocalProxy: checked })
|
||||
}
|
||||
onToggleProxy={handleToggleProxy}
|
||||
isProxyPending={isProxyPending}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Auto Failover */}
|
||||
<AccordionItem
|
||||
value="failover"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.failover.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.failover.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<div className="space-y-6">
|
||||
{!isRunning && (
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t("proxy.failover.proxyRequired", {
|
||||
defaultValue: "需要先启动代理服务才能配置故障转移",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="claude" className="mt-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="mt-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="gemini" className="mt-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Rectifier */}
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Global Outbound Proxy */}
|
||||
<AccordionItem
|
||||
value="globalProxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Globe className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.globalProxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.globalProxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<GlobalProxySettings />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showProxyConfirm}
|
||||
variant="info"
|
||||
title={t("confirm.proxy.title")}
|
||||
message={t("confirm.proxy.message")}
|
||||
confirmText={t("confirm.proxy.confirm")}
|
||||
onConfirm={() => void handleProxyConfirm()}
|
||||
onCancel={() => setShowProxyConfirm(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,11 @@ import {
|
||||
Loader2,
|
||||
Save,
|
||||
FolderSearch,
|
||||
Activity,
|
||||
Coins,
|
||||
Database,
|
||||
Server,
|
||||
ChevronDown,
|
||||
Zap,
|
||||
Globe,
|
||||
Cloud,
|
||||
ScrollText,
|
||||
HardDriveDownload,
|
||||
} from "lucide-react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -39,24 +34,18 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
|
||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { BackupListSection } from "@/components/settings/BackupListSection";
|
||||
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
||||
import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
||||
// Hidden: stream check feature disabled
|
||||
// import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
||||
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
@@ -188,25 +177,6 @@ export function SettingsPage({
|
||||
|
||||
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
startProxyServer,
|
||||
stopWithRestore,
|
||||
isPending: isProxyPending,
|
||||
} = useProxyStatus();
|
||||
|
||||
const handleToggleProxy = async (checked: boolean) => {
|
||||
try {
|
||||
if (!checked) {
|
||||
await stopWithRestore();
|
||||
} else {
|
||||
await startProxyServer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Toggle proxy failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden px-6">
|
||||
{isBusy ? (
|
||||
@@ -219,10 +189,11 @@ export function SettingsPage({
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-4 mb-6 glass rounded-lg">
|
||||
<TabsList className="grid w-full grid-cols-5 mb-6 glass rounded-lg">
|
||||
<TabsTrigger value="general">
|
||||
{t("settings.tabGeneral")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
@@ -269,6 +240,15 @@ export function SettingsPage({
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="proxy" className="space-y-6 mt-0 pb-4">
|
||||
{settings ? (
|
||||
<ProxyTabContent
|
||||
settings={settings}
|
||||
onAutoSave={handleAutoSave}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
||||
{settings ? (
|
||||
<motion.div
|
||||
@@ -317,256 +297,6 @@ export function SettingsPage({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="proxy"
|
||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
onCheckedChange={handleToggleProxy}
|
||||
disabled={isProxyPending}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
|
||||
<ProxyPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="failover"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.failover.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.failover.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<div className="space-y-6">
|
||||
{/* 代理未运行时的提示 */}
|
||||
{!isRunning && (
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t("proxy.failover.proxyRequired", {
|
||||
defaultValue:
|
||||
"需要先启动代理服务才能配置故障转移",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 故障转移设置 - 按应用分组 */}
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="claude"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="codex"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gemini"
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="test"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Activity className="h-5 w-5 text-indigo-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.modelTest.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.modelTest.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<ModelTestConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="pricing"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Coins className="h-5 w-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.pricing.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.pricing.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<PricingConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="globalProxy"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Globe className="h-5 w-5 text-cyan-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.globalProxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.globalProxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<GlobalProxySettings />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="data"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
@@ -596,11 +326,61 @@ export function SettingsPage({
|
||||
onExport={exportConfig}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<WebdavSyncSection
|
||||
config={settings?.webdavSync}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="backup"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<HardDriveDownload className="h-5 w-5 text-amber-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.backup.title", {
|
||||
defaultValue: "Backup & Restore",
|
||||
})}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.backup.description", {
|
||||
defaultValue:
|
||||
"Manage automatic backups, view and restore database snapshots",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<BackupListSection
|
||||
backupIntervalHours={settings.backupIntervalHours}
|
||||
backupRetainCount={settings.backupRetainCount}
|
||||
onSettingsChange={(updates) =>
|
||||
handleAutoSave(updates)
|
||||
}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="cloudSync"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Cloud className="h-5 w-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.cloudSync.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.cloudSync.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<WebdavSyncSection config={settings?.webdavSync} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
|
||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
interface WindowSettingsProps {
|
||||
settings: SettingsFormState;
|
||||
@@ -27,13 +28,25 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
||||
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<EyeOff className="h-4 w-4 text-green-500" />}
|
||||
title={t("settings.silentStartup")}
|
||||
description={t("settings.silentStartupDescription")}
|
||||
checked={!!settings.silentStartup}
|
||||
onCheckedChange={(value) => onChange({ silentStartup: value })}
|
||||
/>
|
||||
<AnimatePresence initial={false}>
|
||||
{settings.launchOnStartup && (
|
||||
<motion.div
|
||||
key="silent-startup"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<ToggleRow
|
||||
icon={<EyeOff className="h-4 w-4 text-green-500" />}
|
||||
title={t("settings.silentStartup")}
|
||||
description={t("settings.silentStartupDescription")}
|
||||
checked={!!settings.silentStartup}
|
||||
onCheckedChange={(value) => onChange({ silentStartup: value })}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ToggleRow
|
||||
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { AppId } from "@/lib/api/types";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { settingsApi, skillsApi } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { APP_IDS } from "@/config/appConfig";
|
||||
import { MCP_SKILLS_APP_IDS } from "@/config/appConfig";
|
||||
import { AppCountBar } from "@/components/common/AppCountBar";
|
||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||
@@ -56,7 +56,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||
if (!skills) return counts;
|
||||
skills.forEach((skill) => {
|
||||
for (const app of APP_IDS) {
|
||||
for (const app of MCP_SKILLS_APP_IDS) {
|
||||
if (skill.apps[app]) counts[app]++;
|
||||
}
|
||||
});
|
||||
@@ -159,6 +159,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
||||
<AppCountBar
|
||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||
counts={enabledCounts}
|
||||
appIds={MCP_SKILLS_APP_IDS}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
@@ -282,6 +283,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
||||
<AppToggleGroup
|
||||
apps={skill.apps}
|
||||
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
||||
appIds={MCP_SKILLS_APP_IDS}
|
||||
/>
|
||||
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -113,33 +113,17 @@ export function ThemeProvider({
|
||||
}
|
||||
};
|
||||
|
||||
// Determine current effective theme
|
||||
// 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.
|
||||
if (theme === "system") {
|
||||
const isDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
updateNativeTheme(isDark ? "dark" : "light");
|
||||
updateNativeTheme("system");
|
||||
} 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]);
|
||||
|
||||
|
||||
@@ -8,10 +8,23 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
|
||||
import { ModelStatsTable } from "./ModelStatsTable";
|
||||
import type { TimeRange } from "@/types/usage";
|
||||
import { motion } from "framer-motion";
|
||||
import { BarChart3, ListFilter, Activity, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
ListFilter,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
Coins,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usageKeys } from "@/lib/query/usage";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||
|
||||
export function UsageDashboard() {
|
||||
const { t } = useTranslation();
|
||||
@@ -129,6 +142,31 @@ export function UsageDashboard() {
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Pricing Configuration */}
|
||||
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
|
||||
<AccordionItem
|
||||
value="pricing"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<Coins className="h-5 w-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.pricing.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.pricing.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<PricingConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} 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 { 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";
|
||||
|
||||
interface DailyMemoryPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getTodayFilename(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}.md`;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// List state
|
||||
const [files, setFiles] = useState<DailyMemoryFileInfo[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
|
||||
// Edit state
|
||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 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);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
try {
|
||||
const list = await workspaceApi.listDailyMemoryFiles();
|
||||
setFiles(list);
|
||||
} catch (err) {
|
||||
console.error("Failed to load daily memory files:", err);
|
||||
toast.error(t("workspace.dailyMemory.loadFailed"));
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadFiles();
|
||||
}
|
||||
}, [isOpen, loadFiles]);
|
||||
|
||||
// Open file for editing
|
||||
const openFile = useCallback(
|
||||
async (filename: string) => {
|
||||
setLoadingContent(true);
|
||||
setEditingFile(filename);
|
||||
try {
|
||||
const data = await workspaceApi.readDailyMemoryFile(filename);
|
||||
setContent(data ?? "");
|
||||
} catch (err) {
|
||||
console.error("Failed to read daily memory file:", err);
|
||||
toast.error(t("workspace.dailyMemory.loadFailed"));
|
||||
setEditingFile(null);
|
||||
} finally {
|
||||
setLoadingContent(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Create today's note (deferred — file is only persisted on save)
|
||||
const handleCreateToday = useCallback(async () => {
|
||||
const filename = getTodayFilename();
|
||||
// Check if already exists in the list
|
||||
const existing = files.find((f) => f.filename === filename);
|
||||
if (existing) {
|
||||
// Just open it
|
||||
await openFile(filename);
|
||||
return;
|
||||
}
|
||||
// Open editor with empty content — no file created until user saves
|
||||
setEditingFile(filename);
|
||||
setContent("");
|
||||
}, [files, openFile]);
|
||||
|
||||
// Save current file
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editingFile) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await workspaceApi.writeDailyMemoryFile(editingFile, content);
|
||||
toast.success(t("workspace.saveSuccess"));
|
||||
} catch (err) {
|
||||
console.error("Failed to save daily memory file:", err);
|
||||
toast.error(t("workspace.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editingFile, content, t]);
|
||||
|
||||
// Delete file
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deletingFile) return;
|
||||
try {
|
||||
await workspaceApi.deleteDailyMemoryFile(deletingFile);
|
||||
toast.success(t("workspace.dailyMemory.deleteSuccess"));
|
||||
setDeletingFile(null);
|
||||
// If we were editing this file, go back to list
|
||||
if (editingFile === deletingFile) {
|
||||
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,
|
||||
]);
|
||||
|
||||
// Back from edit mode to list mode — preserve search state
|
||||
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]);
|
||||
|
||||
// Close panel entirely — clear search state
|
||||
const handleClose = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setContent("");
|
||||
setIsSearchOpen(false);
|
||||
setSearchTerm("");
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// --- Edit mode ---
|
||||
if (editingFile) {
|
||||
return (
|
||||
<>
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("workspace.editing", { filename: editingFile })}
|
||||
onClose={handleBackToList}
|
||||
footer={
|
||||
<Button onClick={handleSave} disabled={saving || loadingContent}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`# ${editingFile}\n\n...`}
|
||||
minHeight="calc(100vh - 240px)"
|
||||
/>
|
||||
)}
|
||||
</FullScreenPanel>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletingFile}
|
||||
title={t("workspace.dailyMemory.confirmDeleteTitle")}
|
||||
message={t("workspace.dailyMemory.confirmDeleteMessage", {
|
||||
date: deletingFile?.replace(".md", "") ?? "",
|
||||
})}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeletingFile(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- List mode ---
|
||||
return (
|
||||
<>
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
title={t("workspace.dailyMemory.title")}
|
||||
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")}
|
||||
>
|
||||
~/.openclaw/workspace/memory/
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={isSearchOpen ? closeSearch : openSearch}
|
||||
title={t("workspace.dailyMemory.searchScopeHint")}
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateToday}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t("workspace.dailyMemory.createToday")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<AnimatePresence>
|
||||
{isSearchOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={t("workspace.dailyMemory.searchPlaceholder")}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => handleSearchChange("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={closeSearch}
|
||||
className="text-xs text-muted-foreground h-8 px-2 shrink-0"
|
||||
>
|
||||
{t("workspace.dailyMemory.searchCloseHint")}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Content: search results or normal file list */}
|
||||
{isActiveSearch ? (
|
||||
// --- Search results ---
|
||||
searching ? (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||
{t("workspace.dailyMemory.searching")}
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3 border-2 border-dashed border-border rounded-xl">
|
||||
<Search className="w-10 h-10 opacity-40" />
|
||||
<p className="text-sm">
|
||||
{t("workspace.dailyMemory.noSearchResults")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{searchResults.map((result) => (
|
||||
<button
|
||||
key={result.filename}
|
||||
onClick={() => openFile(result.filename)}
|
||||
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border bg-card hover:bg-accent/50 transition-colors text-left group"
|
||||
>
|
||||
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{result.date}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(result.sizeBytes)}
|
||||
</span>
|
||||
{result.matchCount > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||
{t("workspace.dailyMemory.matchCount", {
|
||||
count: result.matchCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 whitespace-pre-line">
|
||||
{result.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingFile(result.filename);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : // --- Normal file list ---
|
||||
loadingList ? (
|
||||
<div className="flex items-center justify-center h-48 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground gap-3">
|
||||
<Calendar className="w-10 h-10 opacity-40" />
|
||||
<p className="text-sm">{t("workspace.dailyMemory.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.filename}
|
||||
onClick={() => openFile(file.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">
|
||||
{file.date}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
{file.preview && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{file.preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeletingFile(file.filename);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FullScreenPanel>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletingFile}
|
||||
title={t("workspace.dailyMemory.confirmDeleteTitle")}
|
||||
message={t("workspace.dailyMemory.confirmDeleteMessage", {
|
||||
date: deletingFile?.replace(".md", "") ?? "",
|
||||
})}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeletingFile(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyMemoryPanel;
|
||||
@@ -12,10 +12,14 @@ import {
|
||||
Power,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
import WorkspaceFileEditor from "./WorkspaceFileEditor";
|
||||
import DailyMemoryPanel from "./DailyMemoryPanel";
|
||||
|
||||
interface WorkspaceFile {
|
||||
filename: string;
|
||||
@@ -51,6 +55,7 @@ const WorkspaceFilesPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
|
||||
const [showDailyMemory, setShowDailyMemory] = useState(false);
|
||||
|
||||
const checkFileExistence = async () => {
|
||||
const results: Record<string, boolean> = {};
|
||||
@@ -79,8 +84,13 @@ const WorkspaceFilesPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-4 pb-8">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
<p
|
||||
className="text-sm text-muted-foreground mb-6 cursor-pointer hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
onClick={() => workspaceApi.openDirectory("workspace")}
|
||||
title={t("workspace.openDirectory")}
|
||||
>
|
||||
~/.openclaw/workspace/
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -115,6 +125,27 @@ const WorkspaceFilesPanel: React.FC = () => {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Daily Memory — inline with workspace files */}
|
||||
<button
|
||||
onClick={() => setShowDailyMemory(true)}
|
||||
className="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-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{t("workspace.dailyMemory.cardTitle")}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("workspace.dailyMemory.cardDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WorkspaceFileEditor
|
||||
@@ -122,6 +153,11 @@ const WorkspaceFilesPanel: React.FC = () => {
|
||||
isOpen={!!editingFile}
|
||||
onClose={handleEditorClose}
|
||||
/>
|
||||
|
||||
<DailyMemoryPanel
|
||||
isOpen={showDailyMemory}
|
||||
onClose={() => setShowDailyMemory(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,14 @@ export const APP_IDS: AppId[] = [
|
||||
"openclaw",
|
||||
];
|
||||
|
||||
/** App IDs shown in MCP & Skills panels (excludes OpenClaw) */
|
||||
export const MCP_SKILLS_APP_IDS: AppId[] = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
];
|
||||
|
||||
export const APP_ICON_MAP: Record<AppId, AppConfig> = {
|
||||
claude: {
|
||||
label: "Claude",
|
||||
|
||||
@@ -92,10 +92,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
|
||||
ANTHROPIC_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -110,10 +110,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.7",
|
||||
ANTHROPIC_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -170,10 +170,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-4.7",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ZhipuAI/GLM-5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ZhipuAI/GLM-5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ZhipuAI/GLM-5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -236,10 +236,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -262,10 +262,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1,
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.1",
|
||||
ANTHROPIC_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMax-M2.5",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -287,10 +287,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
ANTHROPIC_MODEL: "doubao-seed-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-code-preview-latest",
|
||||
ANTHROPIC_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -304,10 +304,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.tbox.cn/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "Ling-1T",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Ling-1T",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Ling-1T",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-1T",
|
||||
ANTHROPIC_MODEL: "Ling-2.5-1T",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Ling-2.5-1T",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Ling-2.5-1T",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Ling-2.5-1T",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -338,10 +338,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -356,10 +356,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.5",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -478,6 +478,61 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://www.aicoding.sh",
|
||||
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.aicoding.sh",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: ["https://api.aicoding.sh"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "aicoding", // 促销信息 i18n key
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "CrazyRouter",
|
||||
websiteUrl: "https://www.crazyrouter.com",
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://crazyrouter.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: ["https://crazyrouter.com"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "crazyrouter", // 促销信息 i18n key
|
||||
icon: "crazyrouter",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "SSSAiCode",
|
||||
websiteUrl: "https://www.sssaicode.com",
|
||||
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://node-hk.sssaicode.com/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: [
|
||||
"https://node-hk.sssaicode.com/api",
|
||||
"https://claude2.sssaicode.com/api",
|
||||
"https://anti.sssaicode.com/api",
|
||||
],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "sssaicode", // 促销信息 i18n key
|
||||
icon: "sssaicode",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
@@ -486,10 +541,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.5",
|
||||
ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -533,4 +588,71 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "xiaomimimo",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "AWS Bedrock (AKSK)",
|
||||
websiteUrl: "https://aws.amazon.com/bedrock/",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL:
|
||||
"https://bedrock-runtime.${AWS_REGION}.amazonaws.com",
|
||||
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}",
|
||||
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}",
|
||||
AWS_REGION: "${AWS_REGION}",
|
||||
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-6-v1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL:
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "global.anthropic.claude-sonnet-4-6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-6-v1",
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
},
|
||||
},
|
||||
category: "cloud_provider",
|
||||
templateValues: {
|
||||
AWS_REGION: {
|
||||
label: "AWS Region",
|
||||
placeholder: "us-west-2",
|
||||
editorValue: "us-west-2",
|
||||
},
|
||||
AWS_ACCESS_KEY_ID: {
|
||||
label: "Access Key ID",
|
||||
placeholder: "AKIA...",
|
||||
editorValue: "",
|
||||
},
|
||||
AWS_SECRET_ACCESS_KEY: {
|
||||
label: "Secret Access Key",
|
||||
placeholder: "your-secret-key",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
icon: "aws",
|
||||
iconColor: "#FF9900",
|
||||
},
|
||||
{
|
||||
name: "AWS Bedrock (API Key)",
|
||||
websiteUrl: "https://aws.amazon.com/bedrock/",
|
||||
settingsConfig: {
|
||||
apiKey: "",
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL:
|
||||
"https://bedrock-runtime.${AWS_REGION}.amazonaws.com",
|
||||
AWS_REGION: "${AWS_REGION}",
|
||||
ANTHROPIC_MODEL: "global.anthropic.claude-opus-4-6-v1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL:
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "global.anthropic.claude-sonnet-4-6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "global.anthropic.claude-opus-4-6-v1",
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
},
|
||||
},
|
||||
category: "cloud_provider",
|
||||
templateValues: {
|
||||
AWS_REGION: {
|
||||
label: "AWS Region",
|
||||
placeholder: "us-west-2",
|
||||
editorValue: "us-west-2",
|
||||
},
|
||||
},
|
||||
icon: "aws",
|
||||
iconColor: "#FF9900",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -227,6 +227,59 @@ requires_openai_auth = true`,
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://www.aicoding.sh",
|
||||
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"aicoding",
|
||||
"https://api.aicoding.sh",
|
||||
"gpt-5.3-codex",
|
||||
),
|
||||
endpointCandidates: ["https://api.aicoding.sh"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "CrazyRouter",
|
||||
websiteUrl: "https://www.crazyrouter.com",
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"crazyrouter",
|
||||
"https://crazyrouter.com/v1",
|
||||
"gpt-5.3-codex",
|
||||
),
|
||||
endpointCandidates: ["https://crazyrouter.com/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "crazyrouter",
|
||||
icon: "crazyrouter",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "SSSAiCode",
|
||||
websiteUrl: "https://www.sssaicode.com",
|
||||
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"sssaicode",
|
||||
"https://node-hk.sssaicode.com/api",
|
||||
"gpt-5.3-codex",
|
||||
),
|
||||
endpointCandidates: [
|
||||
"https://node-hk.sssaicode.com/api/v1",
|
||||
"https://claude2.sssaicode.com/api/v1",
|
||||
"https://anti.sssaicode.com/api/v1",
|
||||
],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "sssaicode", // 促销信息 i18n key
|
||||
icon: "sssaicode",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
|
||||
@@ -139,6 +139,70 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
icon: "aicodemirror",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://www.aicoding.sh",
|
||||
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.aicoding.sh",
|
||||
GEMINI_MODEL: "gemini-3-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.aicoding.sh",
|
||||
model: "gemini-3-pro",
|
||||
description: "AICoding",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
endpointCandidates: ["https://api.aicoding.sh"],
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "CrazyRouter",
|
||||
websiteUrl: "https://www.crazyrouter.com",
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://crazyrouter.com",
|
||||
GEMINI_MODEL: "gemini-3-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://crazyrouter.com",
|
||||
model: "gemini-3-pro",
|
||||
description: "CrazyRouter",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "crazyrouter",
|
||||
endpointCandidates: ["https://crazyrouter.com"],
|
||||
icon: "crazyrouter",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "SSSAiCode",
|
||||
websiteUrl: "https://www.sssaicode.com",
|
||||
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://node-hk.sssaicode.com/api",
|
||||
GEMINI_MODEL: "gemini-3-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://node-hk.sssaicode.com/api",
|
||||
model: "gemini-3-pro",
|
||||
description: "SSSAiCode",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sssaicode",
|
||||
endpointCandidates: [
|
||||
"https://node-hk.sssaicode.com/api",
|
||||
"https://claude2.sssaicode.com/api",
|
||||
"https://anti.sssaicode.com/api",
|
||||
],
|
||||
icon: "sssaicode",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
|
||||
@@ -112,16 +112,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
id: "glm-5",
|
||||
name: "GLM-5",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.001 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -138,8 +136,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "zhipu/glm-4.7" },
|
||||
modelCatalog: { "zhipu/glm-4.7": { alias: "GLM" } },
|
||||
model: { primary: "zhipu/glm-5" },
|
||||
modelCatalog: { "zhipu/glm-5": { alias: "GLM" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -152,16 +150,14 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
id: "glm-5",
|
||||
name: "GLM-5",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.001 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "zhipu",
|
||||
icon: "zhipu",
|
||||
iconColor: "#0F62FE",
|
||||
templateValues: {
|
||||
@@ -178,8 +174,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "zhipu-en/glm-4.7" },
|
||||
modelCatalog: { "zhipu-en/glm-4.7": { alias: "GLM" } },
|
||||
model: { primary: "zhipu-en/glm-5" },
|
||||
modelCatalog: { "zhipu-en/glm-5": { alias: "GLM" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -192,8 +188,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "qwen3-max",
|
||||
name: "Qwen3 Max",
|
||||
id: "qwen3.5-plus",
|
||||
name: "Qwen3.5 Plus",
|
||||
contextWindow: 32000,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
},
|
||||
@@ -216,8 +212,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "qwen/qwen3-max" },
|
||||
modelCatalog: { "qwen/qwen3-max": { alias: "Qwen" } },
|
||||
model: { primary: "qwen/qwen3.5-plus" },
|
||||
modelCatalog: { "qwen/qwen3.5-plus": { alias: "Qwen" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -306,8 +302,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -330,8 +326,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.1" },
|
||||
modelCatalog: { "minimax/MiniMax-M2.1": { alias: "MiniMax" } },
|
||||
model: { primary: "minimax/MiniMax-M2.5" },
|
||||
modelCatalog: { "minimax/MiniMax-M2.5": { alias: "MiniMax" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -344,8 +340,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -368,8 +364,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "minimax-en/MiniMax-M2.1" },
|
||||
modelCatalog: { "minimax-en/MiniMax-M2.1": { alias: "MiniMax" } },
|
||||
model: { primary: "minimax-en/MiniMax-M2.5" },
|
||||
modelCatalog: { "minimax-en/MiniMax-M2.5": { alias: "MiniMax" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -465,7 +461,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-code-preview-latest",
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
name: "DouBao Seed Code Preview",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
@@ -483,9 +479,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "doubaoseed/doubao-seed-code-preview-latest" },
|
||||
model: { primary: "doubaoseed/doubao-seed-2-0-code-preview-latest" },
|
||||
modelCatalog: {
|
||||
"doubaoseed/doubao-seed-code-preview-latest": { alias: "DouBao" },
|
||||
"doubaoseed/doubao-seed-2-0-code-preview-latest": { alias: "DouBao" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -498,8 +494,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "Ling-1T",
|
||||
name: "Ling 1T",
|
||||
id: "Ling-2.5-1T",
|
||||
name: "Ling 2.5 1T",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -514,8 +510,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "bailing/Ling-1T" },
|
||||
modelCatalog: { "bailing/Ling-1T": { alias: "BaiLing" } },
|
||||
model: { primary: "bailing/Ling-2.5-1T" },
|
||||
modelCatalog: { "bailing/Ling-2.5-1T": { alias: "BaiLing" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -562,8 +558,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -587,11 +583,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aihubmix/claude-sonnet-4-5-20250929",
|
||||
primary: "aihubmix/claude-sonnet-4-6",
|
||||
fallbacks: ["aihubmix/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aihubmix/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"aihubmix/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"aihubmix/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -606,8 +602,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -631,11 +627,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "dmxapi/claude-sonnet-4-5-20250929",
|
||||
primary: "dmxapi/claude-sonnet-4-6",
|
||||
fallbacks: ["dmxapi/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"dmxapi/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"dmxapi/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"dmxapi/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -650,8 +646,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -675,11 +671,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
primary: "openrouter/anthropic/claude-sonnet-4.6",
|
||||
fallbacks: ["openrouter/anthropic/claude-opus-4.6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"openrouter/anthropic/claude-sonnet-4.5": { alias: "Sonnet" },
|
||||
"openrouter/anthropic/claude-sonnet-4.6": { alias: "Sonnet" },
|
||||
"openrouter/anthropic/claude-opus-4.6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -694,8 +690,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "ZhipuAI/GLM-4.7",
|
||||
name: "GLM-4.7",
|
||||
id: "ZhipuAI/GLM-5",
|
||||
name: "GLM-5",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.001, output: 0.001 },
|
||||
},
|
||||
@@ -718,8 +714,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "modelscope/ZhipuAI/GLM-4.7" },
|
||||
modelCatalog: { "modelscope/ZhipuAI/GLM-4.7": { alias: "GLM" } },
|
||||
model: { primary: "modelscope/ZhipuAI/GLM-5" },
|
||||
modelCatalog: { "modelscope/ZhipuAI/GLM-5": { alias: "GLM" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -732,8 +728,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "Pro/MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -750,9 +746,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.1" },
|
||||
model: { primary: "siliconflow/Pro/MiniMaxAI/MiniMax-M2.5" },
|
||||
modelCatalog: {
|
||||
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
|
||||
"siliconflow/Pro/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -766,8 +762,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.001, output: 0.004 },
|
||||
},
|
||||
@@ -784,9 +780,9 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.1" },
|
||||
model: { primary: "siliconflow-en/MiniMaxAI/MiniMax-M2.5" },
|
||||
modelCatalog: {
|
||||
"siliconflow-en/MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax" },
|
||||
"siliconflow-en/MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -834,8 +830,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -860,11 +856,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "packycode/claude-sonnet-4-5-20250929",
|
||||
primary: "packycode/claude-sonnet-4-6",
|
||||
fallbacks: ["packycode/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"packycode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"packycode/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"packycode/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -879,8 +875,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -906,11 +902,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "cubence/claude-sonnet-4-5-20250929",
|
||||
primary: "cubence/claude-sonnet-4-6",
|
||||
fallbacks: ["cubence/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"cubence/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"cubence/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"cubence/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -925,8 +921,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -952,11 +948,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aigocode/claude-sonnet-4-5-20250929",
|
||||
primary: "aigocode/claude-sonnet-4-6",
|
||||
fallbacks: ["aigocode/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aigocode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"aigocode/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"aigocode/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -971,8 +967,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -998,11 +994,11 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "rightcode/claude-sonnet-4-5-20250929",
|
||||
primary: "rightcode/claude-sonnet-4-6",
|
||||
fallbacks: ["rightcode/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"rightcode/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"rightcode/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"rightcode/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -1017,8 +1013,8 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
@@ -1044,15 +1040,187 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aicodemirror/claude-sonnet-4-5-20250929",
|
||||
primary: "aicodemirror/claude-sonnet-4-6",
|
||||
fallbacks: ["aicodemirror/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aicodemirror/claude-sonnet-4-5-20250929": { alias: "Sonnet" },
|
||||
"aicodemirror/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"aicodemirror/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://www.aicoding.sh",
|
||||
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.aicoding.sh",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "aicoding/claude-sonnet-4-6",
|
||||
fallbacks: ["aicoding/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"aicoding/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"aicoding/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CrazyRouter",
|
||||
websiteUrl: "https://www.crazyrouter.com",
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://crazyrouter.com/v1",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "crazyrouter",
|
||||
icon: "crazyrouter",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "crazyrouter/claude-sonnet-4-6",
|
||||
fallbacks: ["crazyrouter/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"crazyrouter/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"crazyrouter/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SSSAiCode",
|
||||
websiteUrl: "https://www.sssaicode.com",
|
||||
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://node-hk.sssaicode.com/api",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sssaicode",
|
||||
icon: "sssaicode",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "sssaicode/claude-sonnet-4-6",
|
||||
fallbacks: ["sssaicode/claude-opus-4-6"],
|
||||
},
|
||||
modelCatalog: {
|
||||
"sssaicode/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"sssaicode/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// ========== Cloud Providers ==========
|
||||
{
|
||||
name: "AWS Bedrock",
|
||||
websiteUrl: "https://aws.amazon.com/bedrock/",
|
||||
settingsConfig: {
|
||||
// 请将 us-west-2 替换为你的 AWS Region
|
||||
baseUrl: "https://bedrock-runtime.us-west-2.amazonaws.com",
|
||||
apiKey: "",
|
||||
api: "bedrock-converse-stream",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic.claude-opus-4-6-20250514-v1:0",
|
||||
name: "Claude Opus 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
},
|
||||
{
|
||||
id: "anthropic.claude-haiku-4-5-20251022-v1:0",
|
||||
name: "Claude Haiku 4.5",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cloud_provider",
|
||||
icon: "aws",
|
||||
iconColor: "#FF9900",
|
||||
},
|
||||
|
||||
// ========== Custom Template ==========
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ export const opencodeNpmPackages = [
|
||||
{ value: "@ai-sdk/openai", label: "OpenAI" },
|
||||
{ value: "@ai-sdk/openai-compatible", label: "OpenAI Compatible" },
|
||||
{ value: "@ai-sdk/anthropic", label: "Anthropic" },
|
||||
{ value: "@ai-sdk/amazon-bedrock", label: "Amazon Bedrock" },
|
||||
{ value: "@ai-sdk/google", label: "Google (Gemini)" },
|
||||
] as const;
|
||||
|
||||
@@ -40,15 +41,15 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
> = {
|
||||
"@ai-sdk/openai-compatible": [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
contextLimit: 204800,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "GLM 4.7",
|
||||
id: "glm-5",
|
||||
name: "GLM 5",
|
||||
contextLimit: 204800,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
@@ -315,6 +316,50 @@ export const OPENCODE_PRESET_MODEL_VARIANTS: Record<
|
||||
},
|
||||
},
|
||||
],
|
||||
"@ai-sdk/amazon-bedrock": [
|
||||
{
|
||||
id: "global.anthropic.claude-opus-4-6-v1",
|
||||
name: "Claude Opus 4.6",
|
||||
contextLimit: 1000000,
|
||||
outputLimit: 128000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "global.anthropic.claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextLimit: 200000,
|
||||
outputLimit: 64000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
name: "Claude Haiku 4.5",
|
||||
contextLimit: 200000,
|
||||
outputLimit: 64000,
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "us.amazon.nova-pro-v1:0",
|
||||
name: "Amazon Nova Pro",
|
||||
contextLimit: 300000,
|
||||
outputLimit: 5000,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "us.meta.llama4-maverick-17b-instruct-v1:0",
|
||||
name: "Meta Llama 4 Maverick",
|
||||
contextLimit: 131072,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
{
|
||||
id: "us.deepseek.r1-v1:0",
|
||||
name: "DeepSeek R1",
|
||||
contextLimit: 131072,
|
||||
outputLimit: 131072,
|
||||
modalities: { input: ["text"], output: ["text"] },
|
||||
},
|
||||
],
|
||||
"@ai-sdk/anthropic": [
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
@@ -441,7 +486,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"glm-4.7": { name: "GLM-4.7" },
|
||||
"glm-5": { name: "GLM-5" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -473,7 +518,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"glm-4.7": { name: "GLM-4.7" },
|
||||
"glm-5": { name: "GLM-5" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -599,7 +644,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"ZhipuAI/GLM-4.7": { name: "GLM-4.7" },
|
||||
"ZhipuAI/GLM-5": { name: "GLM-5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -703,7 +748,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1" },
|
||||
"MiniMax-M2.5": { name: "MiniMax M2.5" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -735,7 +780,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"MiniMax-M2.1": { name: "MiniMax M2.1" },
|
||||
"MiniMax-M2.5": { name: "MiniMax M2.5" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -767,7 +812,9 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"doubao-seed-code-preview-latest": { name: "Doubao Seed Code Preview" },
|
||||
"doubao-seed-2-0-code-preview-latest": {
|
||||
name: "Doubao Seed Code Preview",
|
||||
},
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -792,7 +839,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"Ling-1T": { name: "Ling 1T" },
|
||||
"Ling-2.5-1T": { name: "Ling 2.5-1T" },
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
@@ -843,7 +890,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -870,7 +917,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -897,7 +944,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" },
|
||||
"anthropic/claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -951,7 +998,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -979,7 +1026,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -1008,7 +1055,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-5-20250929": { name: "Claude Sonnet 4.5" },
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -1069,7 +1116,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4.5": { name: "Claude Sonnet 4.5" },
|
||||
"claude-sonnet-4.6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4.6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
@@ -1086,7 +1133,141 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://www.aicoding.sh",
|
||||
apiKeyUrl: "https://www.aicoding.sh/i/CCSWITCH",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "AICoding",
|
||||
options: {
|
||||
baseURL: "https://api.aicoding.sh",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "aicoding",
|
||||
icon: "aicoding",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CrazyRouter",
|
||||
websiteUrl: "https://www.crazyrouter.com",
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "CrazyRouter",
|
||||
options: {
|
||||
baseURL: "https://crazyrouter.com",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "crazyrouter",
|
||||
icon: "crazyrouter",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SSSAiCode",
|
||||
websiteUrl: "https://www.sssaicode.com",
|
||||
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "SSSAiCode",
|
||||
options: {
|
||||
baseURL: "https://node-hk.sssaicode.com/api",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-6": { name: "Claude Opus 4.6" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "sssaicode",
|
||||
icon: "sssaicode",
|
||||
iconColor: "#000000",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AWS Bedrock",
|
||||
websiteUrl: "https://aws.amazon.com/bedrock/",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
name: "AWS Bedrock",
|
||||
options: {
|
||||
region: "${region}",
|
||||
accessKeyId: "${accessKeyId}",
|
||||
secretAccessKey: "${secretAccessKey}",
|
||||
},
|
||||
models: {
|
||||
"global.anthropic.claude-opus-4-6-v1": { name: "Claude Opus 4.6" },
|
||||
"global.anthropic.claude-sonnet-4-6": {
|
||||
name: "Claude Sonnet 4.6",
|
||||
},
|
||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
name: "Claude Haiku 4.5",
|
||||
},
|
||||
"us.amazon.nova-pro-v1:0": { name: "Amazon Nova Pro" },
|
||||
"us.meta.llama4-maverick-17b-instruct-v1:0": {
|
||||
name: "Meta Llama 4 Maverick",
|
||||
},
|
||||
"us.deepseek.r1-v1:0": { name: "DeepSeek R1" },
|
||||
},
|
||||
},
|
||||
category: "cloud_provider",
|
||||
icon: "aws",
|
||||
iconColor: "#FF9900",
|
||||
templateValues: {
|
||||
region: {
|
||||
label: "AWS Region",
|
||||
placeholder: "us-west-2",
|
||||
defaultValue: "us-west-2",
|
||||
editorValue: "us-west-2",
|
||||
},
|
||||
accessKeyId: {
|
||||
label: "Access Key ID",
|
||||
placeholder: "AKIA...",
|
||||
editorValue: "",
|
||||
},
|
||||
secretAccessKey: {
|
||||
label: "Secret Access Key",
|
||||
placeholder: "your-secret-key",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAI Compatible",
|
||||
websiteUrl: "",
|
||||
@@ -1129,4 +1310,17 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
iconColor: "#8B5CF6",
|
||||
isCustomTemplate: true,
|
||||
},
|
||||
{
|
||||
name: "Oh My OpenCode Slim",
|
||||
websiteUrl: "https://github.com/alvinunreal/oh-my-opencode-slim",
|
||||
settingsConfig: {
|
||||
npm: "",
|
||||
options: {},
|
||||
models: {},
|
||||
},
|
||||
category: "omo-slim" as ProviderCategory,
|
||||
icon: "opencode",
|
||||
iconColor: "#6366F1",
|
||||
isCustomTemplate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Detects whether the container's children overflow the available width
|
||||
* and returns a `compact` flag for the AppSwitcher.
|
||||
*
|
||||
* Uses ResizeObserver on a flex-constrained container. The container
|
||||
* must have `flex-1 min-w-0 overflow-hidden` so its width is determined
|
||||
* by the parent layout, not its own content — avoiding the oscillation
|
||||
* problem when toggling compact mode.
|
||||
*/
|
||||
export function useAutoCompact(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
): boolean {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const normalWidthRef = useRef(0);
|
||||
const lockUntilRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
// During expand animation, ignore resize events to prevent flicker
|
||||
if (Date.now() < lockUntilRef.current) return;
|
||||
|
||||
if (!compact) {
|
||||
// Cache the total content width in normal mode
|
||||
normalWidthRef.current = el.scrollWidth;
|
||||
// Overflow detected → switch to compact
|
||||
if (el.scrollWidth > el.clientWidth + 1) {
|
||||
setCompact(true);
|
||||
}
|
||||
} else if (normalWidthRef.current > 0) {
|
||||
// In compact mode: only recover to normal if
|
||||
// available space >= what normal mode needed
|
||||
if (el.clientWidth >= normalWidthRef.current) {
|
||||
// Lock out resize events during the expand animation (200ms + 50ms margin)
|
||||
lockUntilRef.current = Date.now() + 250;
|
||||
setCompact(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [compact]);
|
||||
|
||||
return compact;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { backupsApi } from "@/lib/api";
|
||||
|
||||
export function useBackupManager() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: backups = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["db-backups"],
|
||||
queryFn: () => backupsApi.listDbBackups(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => backupsApi.createDbBackup(),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),
|
||||
onSuccess: async () => {
|
||||
// Invalidate all queries to refresh data from restored database
|
||||
await queryClient.invalidateQueries();
|
||||
// Refetch backup list
|
||||
await refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({
|
||||
oldFilename,
|
||||
newName,
|
||||
}: {
|
||||
oldFilename: string;
|
||||
newName: string;
|
||||
}) => backupsApi.renameDbBackup(oldFilename, newName),
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -134,9 +134,20 @@ export function useProviderActions(activeApp: AppId) {
|
||||
const switchProvider = useCallback(
|
||||
async (provider: Provider) => {
|
||||
try {
|
||||
await switchProviderMutation.mutateAsync(provider.id);
|
||||
const result = await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
|
||||
// Show backfill warning if present
|
||||
if (result?.warnings?.length) {
|
||||
toast.warning(
|
||||
t("notifications.backfillWarning", {
|
||||
defaultValue:
|
||||
"切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
|
||||
}),
|
||||
{ duration: 5000 },
|
||||
);
|
||||
}
|
||||
|
||||
// 根据供应商类型显示不同的成功提示
|
||||
if (
|
||||
activeApp === "claude" &&
|
||||
|
||||
@@ -108,9 +108,11 @@ 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 },
|
||||
|
||||
@@ -135,7 +135,8 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings;
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } =
|
||||
mergedSettings;
|
||||
|
||||
const payload: Settings = {
|
||||
...restSettings,
|
||||
@@ -252,7 +253,8 @@ export function useSettings(): UseSettingsResult {
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings;
|
||||
const { webdavSync: _ignoredWebdavSync, ...restSettings } =
|
||||
mergedSettings;
|
||||
|
||||
const payload: Settings = {
|
||||
...restSettings,
|
||||
|
||||
+158
-48
@@ -37,7 +37,8 @@
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"actions": "Actions",
|
||||
"deleting": "Deleting..."
|
||||
"deleting": "Deleting...",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -77,7 +78,9 @@
|
||||
"tabProvider": "Provider",
|
||||
"tabUniversal": "Universal",
|
||||
"noProviders": "No providers added yet",
|
||||
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
|
||||
"noProvidersDescription": "Import your current live config below, or manually add a new provider",
|
||||
"importCurrent": "Import Current Config",
|
||||
"importCurrentDescription": "Import current live config as default provider",
|
||||
"currentlyUsing": "Currently Using",
|
||||
"enable": "Enable",
|
||||
"inUse": "In Use",
|
||||
@@ -91,8 +94,8 @@
|
||||
"addOpenCodeProvider": "Add OpenCode Provider",
|
||||
"addToConfig": "Add",
|
||||
"removeFromConfig": "Remove",
|
||||
"setAsDefault": "Enable",
|
||||
"isDefault": "Default",
|
||||
"setAsDefault": "Set Default",
|
||||
"isDefault": "Current Default",
|
||||
"inConfig": "Added",
|
||||
"addProviderHint": "Fill in the information to quickly switch providers in the list.",
|
||||
"editClaudeProvider": "Edit Claude Code Provider",
|
||||
@@ -171,19 +174,31 @@
|
||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||
"openclawDefaultModelSet": "Set as default model",
|
||||
"openclawDefaultModelSetFailed": "Failed to set default model",
|
||||
"openclawNoModels": "No models configured"
|
||||
"openclawNoModels": "No models configured",
|
||||
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone.",
|
||||
"removeProvider": "Remove Provider",
|
||||
"removeProviderMessage": "Are you sure you want to remove provider \"{{name}}\" from the configuration?\n\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time."
|
||||
"removeProviderMessage": "Are you sure you want to remove provider \"{{name}}\" from the configuration?\n\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time.",
|
||||
"proxy": {
|
||||
"title": "Enable Local Proxy",
|
||||
"message": "Local proxy is an advanced feature. Please make sure you understand how it works before enabling.\n\nWe recommend consulting the relevant documentation or your provider for proper configuration.",
|
||||
"confirm": "I understand, enable"
|
||||
},
|
||||
"usage": {
|
||||
"title": "Configure Usage Query",
|
||||
"message": "Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\n\nIf unsure how to configure, please consult your provider's documentation first.",
|
||||
"confirm": "I understand, configure"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"general": "General",
|
||||
"tabGeneral": "General",
|
||||
"tabAdvanced": "Advanced",
|
||||
"tabProxy": "Proxy",
|
||||
"advanced": {
|
||||
"configDir": {
|
||||
"title": "Configuration Directory",
|
||||
@@ -192,6 +207,8 @@
|
||||
"proxy": {
|
||||
"title": "Local Proxy",
|
||||
"description": "Control proxy service toggle, view status and port info",
|
||||
"enableFeature": "Show Proxy Toggle on Main Page",
|
||||
"enableFeatureDescription": "When enabled, the proxy and failover toggles will appear at the top of the main page",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped"
|
||||
},
|
||||
@@ -213,7 +230,15 @@
|
||||
},
|
||||
"data": {
|
||||
"title": "Data Management",
|
||||
"description": "Import/export configurations and backup/restore"
|
||||
"description": "Import and export local configuration data"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup & Restore",
|
||||
"description": "Manage automatic backups, view and restore database snapshots"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "Cloud Sync",
|
||||
"description": "Sync data across devices via WebDAV"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "Rectifier",
|
||||
@@ -275,6 +300,37 @@
|
||||
"selectFileFailed": "Please choose a valid SQL backup file",
|
||||
"configCorrupted": "SQL file may be corrupted or invalid",
|
||||
"backupId": "Backup ID",
|
||||
"backupManager": {
|
||||
"title": "Database Backups",
|
||||
"description": "Automatic database snapshots for restoring to a previous state",
|
||||
"empty": "No backups yet",
|
||||
"restore": "Restore",
|
||||
"restoring": "Restoring...",
|
||||
"confirmTitle": "Confirm Restore",
|
||||
"confirmMessage": "Restoring this backup will overwrite the current database. A safety backup will be created first.",
|
||||
"restoreSuccess": "Restore successful! Safety backup created",
|
||||
"restoreFailed": "Restore failed",
|
||||
"safetyBackupId": "Safety Backup ID",
|
||||
"intervalLabel": "Auto-backup Interval",
|
||||
"retainLabel": "Backup Retention",
|
||||
"intervalDisabled": "Disabled",
|
||||
"intervalHours": "{{hours}} hours",
|
||||
"intervalDays": "{{days}} days",
|
||||
"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."
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV Cloud Sync",
|
||||
"description": "Sync database and skill configurations across devices via WebDAV.",
|
||||
@@ -448,6 +504,14 @@
|
||||
"oneClickInstall": "One-click Install",
|
||||
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "Local environment check",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux"
|
||||
},
|
||||
"wslShell": "Shell",
|
||||
"wslShellFlag": "Flag",
|
||||
"installCommandsCopied": "Install commands copied",
|
||||
"installCommandsCopyFailed": "Copy failed, please copy manually.",
|
||||
"importFailedError": "Import config failed: {{message}}",
|
||||
@@ -497,7 +561,7 @@
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "Session Manager",
|
||||
"subtitle": "Manage Codex and Claude Code sessions",
|
||||
"subtitle": "Manage Claude Code, Codex, OpenCode, OpenClaw and Gemini CLI sessions",
|
||||
"searchPlaceholder": "Search by content, directory, or ID",
|
||||
"searchSessions": "Search sessions",
|
||||
"providerFilterAll": "All",
|
||||
@@ -583,7 +647,10 @@
|
||||
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
|
||||
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!",
|
||||
"rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!",
|
||||
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!"
|
||||
"aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!",
|
||||
"aicoding": "AI Coding offers an exclusive discount for CC Switch users — 10% off your first top-up!",
|
||||
"crazyrouter": "CrazyRouter offers an exclusive bonus for CC Switch users — 30% extra credit on your first top-up!",
|
||||
"sssaicode": "SSAI Code offers an exclusive bonus for CC Switch users — $10 extra credit on every top-up!"
|
||||
},
|
||||
"parameterConfig": "Parameter Config - {{name}} *",
|
||||
"mainModel": "Main Model (optional)",
|
||||
@@ -746,38 +813,6 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "Provider Key",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.",
|
||||
"providerKeyRequired": "Provider key is required",
|
||||
"providerKeyDuplicate": "This key is already in use",
|
||||
"providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.",
|
||||
"apiProtocol": "API Protocol",
|
||||
"selectProtocol": "Select API Protocol",
|
||||
"apiProtocolHint": "Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.",
|
||||
"baseUrl": "API Endpoint",
|
||||
"baseUrlHint": "The provider's API endpoint address.",
|
||||
"models": "Models",
|
||||
"addModel": "Add Model",
|
||||
"noModels": "No models configured. Click Add Model to configure available models.",
|
||||
"modelId": "Model ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "Display Name",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "Context Window",
|
||||
"maxTokens": "Max Output Tokens",
|
||||
"reasoning": "Reasoning Mode",
|
||||
"reasoningOn": "Enabled",
|
||||
"reasoningOff": "Disabled",
|
||||
"inputCost": "Input Cost ($/M tokens)",
|
||||
"outputCost": "Output Cost ($/M tokens)",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"cacheReadCost": "Cache Read Cost ($/M tokens)",
|
||||
"cacheWriteCost": "Cache Write Cost ($/M tokens)",
|
||||
"cacheCostHint": "Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.",
|
||||
"modelsHint": "Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface."
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "Provider Preset",
|
||||
"custom": "Custom Configuration",
|
||||
@@ -1164,9 +1199,61 @@
|
||||
"editing": "Edit {{filename}}",
|
||||
"saveSuccess": "Saved successfully",
|
||||
"saveFailed": "Failed to save",
|
||||
"loadFailed": "Failed to load"
|
||||
"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",
|
||||
"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)"
|
||||
}
|
||||
},
|
||||
"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.)",
|
||||
@@ -1202,7 +1289,8 @@
|
||||
"description": "Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)",
|
||||
"modelSection": "Model Configuration",
|
||||
"primaryModel": "Primary Model",
|
||||
"primaryModelHint": "Format: provider/model-id",
|
||||
"primaryModelHint": "Set via the \"Set as Default Model\" button in the provider list",
|
||||
"notSet": "Not set",
|
||||
"fallbackModels": "Fallback Models",
|
||||
"fallbackModelsHint": "Comma-separated, ordered by priority",
|
||||
"runtimeSection": "Runtime Parameters",
|
||||
@@ -1494,7 +1582,7 @@
|
||||
"currentProvider": "Current Provider:",
|
||||
"waitingFirstRequest": "Current Provider: Waiting for first request...",
|
||||
"stoppedTitle": "Proxy Service Stopped",
|
||||
"stoppedDescription": "Use the toggle in the top right to start the service",
|
||||
"stoppedDescription": "Use the toggle above to start the service",
|
||||
"openSettings": "Configure Proxy Service",
|
||||
"stats": {
|
||||
"activeConnections": "Active Connections",
|
||||
@@ -1585,6 +1673,12 @@
|
||||
"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",
|
||||
@@ -1752,10 +1846,6 @@
|
||||
"enabled": "Enabled",
|
||||
"disabled": "OMO disabled",
|
||||
"disableFailed": "Failed to disable OMO: {{error}}",
|
||||
"writeCommonConfig": "Write to common config",
|
||||
"editCommonConfig": "Edit common config",
|
||||
"editCommonConfigTitle": "Common Config",
|
||||
"commonConfigHint": "OMO common config will be merged into all OMO configs that enable it",
|
||||
"selectPlaceholder": "Select...",
|
||||
"clear": "Clear",
|
||||
"clearWrapped": "(Clear)",
|
||||
@@ -1785,6 +1875,10 @@
|
||||
"customCategories": "Custom Categories",
|
||||
"modelConfiguration": "Model Configuration",
|
||||
"fillRecommended": "Fill Recommended",
|
||||
"fillRecommendedSuccess": "Filled {{count}} recommended models",
|
||||
"fillRecommendedPartial": "Filled {{filled}} recommended models, {{unmatched}} unmatched",
|
||||
"fillRecommendedAllSet": "All slots already have models configured",
|
||||
"fillRecommendedNoMatch": "Recommended models not found in configured providers",
|
||||
"configSummary": "{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params",
|
||||
"enabledModelsCount": "{{count}} configured models available",
|
||||
"source": "from:",
|
||||
@@ -1858,6 +1952,22 @@
|
||||
"unspecifiedLow": "Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.",
|
||||
"unspecifiedHigh": "Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.6 max variant.",
|
||||
"writing": "Writing category for documentation, prose and technical writing. Defaults to Gemini 3 Flash fast generation model."
|
||||
},
|
||||
"slimAgentDesc": {
|
||||
"orchestrator": "Orchestrator",
|
||||
"oracle": "Oracle",
|
||||
"librarian": "Librarian",
|
||||
"explorer": "Explorer",
|
||||
"designer": "Designer",
|
||||
"fixer": "Fixer"
|
||||
},
|
||||
"slimAgentTooltip": {
|
||||
"orchestrator": "Writes executable code, orchestrates multi-agent workflow, summons experts",
|
||||
"oracle": "Root cause analysis, architecture review, debugging guidance (read-only)",
|
||||
"librarian": "Documentation lookup, GitHub code search (read-only)",
|
||||
"explorer": "Regex search, AST pattern matching, file discovery (read-only)",
|
||||
"designer": "Modern responsive design, CSS/Tailwind expertise",
|
||||
"fixer": "Code implementation, refactoring, testing, verification"
|
||||
}
|
||||
},
|
||||
"openclawConfig": {
|
||||
|
||||
+179
-50
@@ -37,7 +37,8 @@
|
||||
"search": "検索",
|
||||
"reset": "リセット",
|
||||
"actions": "操作",
|
||||
"deleting": "削除中..."
|
||||
"deleting": "削除中...",
|
||||
"auto": "自動"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "API Key を入力",
|
||||
@@ -77,7 +78,9 @@
|
||||
"tabProvider": "プロバイダー",
|
||||
"tabUniversal": "統一プロバイダー",
|
||||
"noProviders": "まだプロバイダーがありません",
|
||||
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
|
||||
"noProvidersDescription": "下の「現在の設定をインポート」ボタンで既存の設定を取り込むか、新しいプロバイダーを手動で追加してください",
|
||||
"importCurrent": "現在の設定をインポート",
|
||||
"importCurrentDescription": "現在使用中の設定をデフォルトプロバイダーとしてインポート",
|
||||
"currentlyUsing": "現在使用中",
|
||||
"enable": "有効化",
|
||||
"inUse": "使用中",
|
||||
@@ -91,8 +94,8 @@
|
||||
"addOpenCodeProvider": "OpenCode プロバイダーを追加",
|
||||
"addToConfig": "追加",
|
||||
"removeFromConfig": "削除",
|
||||
"setAsDefault": "有効化",
|
||||
"isDefault": "デフォルト",
|
||||
"setAsDefault": "デフォルトに設定",
|
||||
"isDefault": "現在のデフォルト",
|
||||
"inConfig": "追加済み",
|
||||
"addProviderHint": "一覧にすばやく切り替えられるよう、ここに情報を入力してください。",
|
||||
"editClaudeProvider": "Claude Code プロバイダーを編集",
|
||||
@@ -171,19 +174,31 @@
|
||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
|
||||
"openclawNoModels": "モデルが設定されていません"
|
||||
"openclawNoModels": "モデルが設定されていません",
|
||||
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
"deleteProviderMessage": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"removeProvider": "プロバイダーを解除",
|
||||
"removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。"
|
||||
"removeProviderMessage": "プロバイダー「{{name}}」を設定から解除してもよろしいですか?\n\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。",
|
||||
"proxy": {
|
||||
"title": "ローカルプロキシの有効化",
|
||||
"message": "ローカルプロキシは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\n\n適切な設定方法については、関連ドキュメントまたはプロバイダーにご相談ください。",
|
||||
"confirm": "理解しました、有効にする"
|
||||
},
|
||||
"usage": {
|
||||
"title": "使用量クエリの設定",
|
||||
"message": "使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\n\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。",
|
||||
"confirm": "理解しました、設定する"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"general": "一般",
|
||||
"tabGeneral": "一般",
|
||||
"tabAdvanced": "詳細",
|
||||
"tabProxy": "プロキシ",
|
||||
"advanced": {
|
||||
"configDir": {
|
||||
"title": "設定ディレクトリ",
|
||||
@@ -192,6 +207,8 @@
|
||||
"proxy": {
|
||||
"title": "ローカルプロキシ",
|
||||
"description": "プロキシサービスの切り替え、ステータスとポート情報を表示",
|
||||
"enableFeature": "メインページにプロキシ切り替えを表示",
|
||||
"enableFeatureDescription": "有効にすると、メインページ上部にプロキシとフェイルオーバーの切り替えが表示されます",
|
||||
"running": "実行中",
|
||||
"stopped": "停止中"
|
||||
},
|
||||
@@ -213,7 +230,15 @@
|
||||
},
|
||||
"data": {
|
||||
"title": "データ管理",
|
||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||
"description": "ローカル設定データのインポートとエクスポート"
|
||||
},
|
||||
"backup": {
|
||||
"title": "バックアップと復元",
|
||||
"description": "自動バックアップの管理、データベーススナップショットの表示と復元"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "クラウド同期",
|
||||
"description": "WebDAV でデバイス間のデータを同期"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "整流器",
|
||||
@@ -275,6 +300,37 @@
|
||||
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
|
||||
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
|
||||
"backupId": "バックアップ ID",
|
||||
"backupManager": {
|
||||
"title": "データベースバックアップ",
|
||||
"description": "以前の状態に復元するための自動データベーススナップショット",
|
||||
"empty": "バックアップはまだありません",
|
||||
"restore": "復元",
|
||||
"restoring": "復元中...",
|
||||
"confirmTitle": "バックアップの復元を確認",
|
||||
"confirmMessage": "このバックアップを復元すると現在のデータベースが上書きされます。安全バックアップが先に作成されます。",
|
||||
"restoreSuccess": "復元成功!安全バックアップが作成されました",
|
||||
"restoreFailed": "復元に失敗しました",
|
||||
"safetyBackupId": "安全バックアップID",
|
||||
"intervalLabel": "自動バックアップ間隔",
|
||||
"retainLabel": "バックアップ保持数",
|
||||
"intervalDisabled": "無効",
|
||||
"intervalHours": "{{hours}} 時間",
|
||||
"intervalDays": "{{days}} 日",
|
||||
"rename": "名前変更",
|
||||
"renameSuccess": "バックアップの名前を変更しました",
|
||||
"renameFailed": "名前変更に失敗しました",
|
||||
"namePlaceholder": "新しい名前を入力",
|
||||
"createBackup": "今すぐバックアップ",
|
||||
"creating": "バックアップ中...",
|
||||
"createSuccess": "バックアップが作成されました",
|
||||
"createFailed": "バックアップに失敗しました",
|
||||
"delete": "削除",
|
||||
"deleting": "削除中...",
|
||||
"deleteSuccess": "バックアップを削除しました",
|
||||
"deleteFailed": "削除に失敗しました",
|
||||
"deleteConfirmTitle": "バックアップの削除を確認",
|
||||
"deleteConfirmMessage": "このバックアップは完全に削除されます。この操作は取り消せません。"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV クラウド同期",
|
||||
"description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。",
|
||||
@@ -448,6 +504,14 @@
|
||||
"oneClickInstall": "ワンクリックインストール",
|
||||
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode をインストール",
|
||||
"localEnvCheck": "ローカル環境チェック",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux"
|
||||
},
|
||||
"wslShell": "Shell",
|
||||
"wslShellFlag": "フラグ",
|
||||
"installCommandsCopied": "インストールコマンドをコピーしました",
|
||||
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
|
||||
"importFailedError": "設定のインポートに失敗しました: {{message}}",
|
||||
@@ -497,7 +561,7 @@
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "セッション管理",
|
||||
"subtitle": "Codex / Claude Code のセッションを管理",
|
||||
"subtitle": "Claude Code / Codex / OpenCode / OpenClaw / Gemini CLI のセッションを管理",
|
||||
"searchPlaceholder": "内容・ディレクトリ・ID で検索",
|
||||
"searchSessions": "セッションを検索",
|
||||
"providerFilterAll": "すべて",
|
||||
@@ -583,7 +647,10 @@
|
||||
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
||||
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
|
||||
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!",
|
||||
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!"
|
||||
"aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!",
|
||||
"aicoding": "AI Coding は CC Switch ユーザー向けに特別割引を提供しています。初回チャージが 10% オフ!",
|
||||
"crazyrouter": "CrazyRouter は CC Switch ユーザー向けに特別ボーナスを提供しています。初回チャージで 30% の追加クレジットがもらえます!",
|
||||
"sssaicode": "SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます!"
|
||||
},
|
||||
"parameterConfig": "パラメーター設定 - {{name}} *",
|
||||
"mainModel": "メインモデル(任意)",
|
||||
@@ -746,38 +813,6 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
"apiProtocol": "API プロトコル",
|
||||
"selectProtocol": "API プロトコルを選択",
|
||||
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
|
||||
"baseUrl": "API エンドポイント",
|
||||
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
|
||||
"models": "モデル一覧",
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "表示名",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"maxTokens": "最大出力トークン数",
|
||||
"reasoning": "推論モード",
|
||||
"reasoningOn": "有効",
|
||||
"reasoningOff": "無効",
|
||||
"inputCost": "入力コスト ($/M トークン)",
|
||||
"outputCost": "出力コスト ($/M トークン)",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
|
||||
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
|
||||
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
|
||||
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "プロバイダータイプ",
|
||||
"custom": "カスタム設定",
|
||||
@@ -1164,9 +1199,61 @@
|
||||
"editing": "{{filename}} を編集",
|
||||
"saveSuccess": "保存しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"loadFailed": "読み込みに失敗しました"
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"openDirectory": "ファイルマネージャーで開く",
|
||||
"dailyMemory": {
|
||||
"title": "デイリーメモリー",
|
||||
"sectionTitle": "デイリーメモリー",
|
||||
"cardTitle": "デイリーメモリーファイル",
|
||||
"cardDescription": "デイリーメモリーの閲覧・管理",
|
||||
"createToday": "メモリーを追加",
|
||||
"empty": "デイリーメモリーファイルはまだありません",
|
||||
"loadFailed": "デイリーメモリーファイルの読み込みに失敗しました",
|
||||
"createFailed": "デイリーメモリーファイルの作成に失敗しました",
|
||||
"deleteSuccess": "デイリーメモリーファイルを削除しました",
|
||||
"deleteFailed": "デイリーメモリーファイルの削除に失敗しました",
|
||||
"confirmDeleteTitle": "デイリーメモリーを削除",
|
||||
"confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。",
|
||||
"searchPlaceholder": "全文検索...",
|
||||
"searchScopeHint": "すべてのデイリーメモリーを全文検索 ⌘F",
|
||||
"searchCloseHint": "Escで閉じる",
|
||||
"noSearchResults": "検索に一致するデイリーメモリーはありません。",
|
||||
"searching": "検索中...",
|
||||
"searchFailed": "検索に失敗しました",
|
||||
"matchCount": "{{count}}件一致"
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "プロバイダーキー",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "設定ファイル内のユニーク識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用可能。",
|
||||
"providerKeyRequired": "プロバイダーキーを入力してください",
|
||||
"providerKeyDuplicate": "このキーは既に使用されています",
|
||||
"providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用可能。",
|
||||
"apiProtocol": "API プロトコル",
|
||||
"selectProtocol": "API プロトコルを選択",
|
||||
"apiProtocolHint": "プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。",
|
||||
"baseUrl": "API エンドポイント",
|
||||
"baseUrlHint": "プロバイダーの API エンドポイントアドレス。",
|
||||
"models": "モデル一覧",
|
||||
"addModel": "モデルを追加",
|
||||
"noModels": "モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。",
|
||||
"modelId": "モデル ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "表示名",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "コンテキストウィンドウ",
|
||||
"maxTokens": "最大出力トークン数",
|
||||
"reasoning": "推論モード",
|
||||
"reasoningOn": "有効",
|
||||
"reasoningOff": "無効",
|
||||
"inputCost": "入力コスト ($/M トークン)",
|
||||
"outputCost": "出力コスト ($/M トークン)",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"cacheReadCost": "キャッシュ読取コスト ($/M トークン)",
|
||||
"cacheWriteCost": "キャッシュ書込コスト ($/M トークン)",
|
||||
"cacheCostHint": "キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。",
|
||||
"modelsHint": "このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。",
|
||||
"env": {
|
||||
"title": "環境変数",
|
||||
"description": "openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)",
|
||||
@@ -1202,7 +1289,8 @@
|
||||
"description": "openclaw.json の agents.defaults 設定を管理(デフォルトモデル、ランタイムパラメータなど)",
|
||||
"modelSection": "モデル設定",
|
||||
"primaryModel": "プライマリモデル",
|
||||
"primaryModelHint": "形式: provider/model-id",
|
||||
"primaryModelHint": "プロバイダ一覧の「デフォルトモデルに設定」ボタンで設定します",
|
||||
"notSet": "未設定",
|
||||
"fallbackModels": "フォールバックモデル",
|
||||
"fallbackModelsHint": "カンマ区切り、優先度順",
|
||||
"runtimeSection": "ランタイムパラメータ",
|
||||
@@ -1295,6 +1383,7 @@
|
||||
"http429": "リクエストが多すぎます。時間をおいて再試行してください",
|
||||
"parseMetadataFailed": "スキルメタデータの解析に失敗しました",
|
||||
"getHomeDirFailed": "ユーザーのホームディレクトリを取得できません",
|
||||
"noSkillsInZip": "ZIP ファイルにスキルが見つかりません(SKILL.md ファイルが必要です)",
|
||||
"networkError": "ネットワークエラー",
|
||||
"fsError": "ファイルシステムエラー",
|
||||
"unknownError": "不明なエラー",
|
||||
@@ -1305,7 +1394,8 @@
|
||||
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
|
||||
"checkDiskSpace": "ディスク容量を確認してください",
|
||||
"checkPermission": "ディレクトリの権限を確認してください",
|
||||
"uninstallFirst": "同名のスキルを先にアンインストールしてください"
|
||||
"uninstallFirst": "同名のスキルを先にアンインストールしてください",
|
||||
"checkZipContent": "ZIP ファイルに有効なスキルディレクトリ(SKILL.md を含む)が含まれていることを確認してください"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
@@ -1492,7 +1582,7 @@
|
||||
"currentProvider": "現在のプロバイダー:",
|
||||
"waitingFirstRequest": "現在のプロバイダー: 最初のリクエスト待ち...",
|
||||
"stoppedTitle": "プロキシサービス停止中",
|
||||
"stoppedDescription": "右上のトグルでサービスを開始できます",
|
||||
"stoppedDescription": "上のトグルでサービスを開始できます",
|
||||
"openSettings": "プロキシサービスを設定",
|
||||
"stats": {
|
||||
"activeConnections": "アクティブ接続",
|
||||
@@ -1575,9 +1665,20 @@
|
||||
"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": "自動フェイルオーバー",
|
||||
@@ -1605,6 +1706,7 @@
|
||||
"info": "フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。",
|
||||
"configSaved": "自動フェイルオーバー設定を保存しました",
|
||||
"configSaveFailed": "保存に失敗しました",
|
||||
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
|
||||
"retrySettings": "リトライとタイムアウト設定",
|
||||
"failureThreshold": "失敗しきい値",
|
||||
"failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)",
|
||||
@@ -1657,6 +1759,16 @@
|
||||
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
||||
"nonStreaming": "非ストリーミングタイムアウト"
|
||||
},
|
||||
"circuitBreaker": {
|
||||
"failureThreshold": "失敗しきい値",
|
||||
"successThreshold": "成功しきい値",
|
||||
"timeoutSeconds": "タイムアウト(秒)",
|
||||
"errorRateThreshold": "エラー率しきい値",
|
||||
"minRequests": "最小リクエスト数",
|
||||
"validationFailed": "以下のフィールドが有効範囲外です: {{fields}}",
|
||||
"configSaved": "サーキットブレーカー設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました"
|
||||
},
|
||||
"universalProvider": {
|
||||
"title": "統合プロバイダー",
|
||||
"description": "統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。",
|
||||
@@ -1681,6 +1793,7 @@
|
||||
"syncError": "同期に失敗しました",
|
||||
"noAppsEnabled": "有効なアプリがありません",
|
||||
"added": "統合プロバイダーを追加しました",
|
||||
"addedAndSynced": "統合プロバイダーを追加して同期しました",
|
||||
"updated": "統合プロバイダーを更新しました",
|
||||
"deleted": "統合プロバイダーを削除しました",
|
||||
"addSuccess": "統合プロバイダーを追加しました",
|
||||
@@ -1733,10 +1846,6 @@
|
||||
"enabled": "有効中",
|
||||
"disabled": "OMO を無効化しました",
|
||||
"disableFailed": "OMO の無効化に失敗しました: {{error}}",
|
||||
"writeCommonConfig": "共通設定に書き込む",
|
||||
"editCommonConfig": "共通設定を編集",
|
||||
"editCommonConfigTitle": "共通設定",
|
||||
"commonConfigHint": "OMO 共通設定は有効にしたすべての OMO 設定に統合されます",
|
||||
"selectPlaceholder": "選択してください...",
|
||||
"clear": "クリア",
|
||||
"clearWrapped": "(クリア)",
|
||||
@@ -1766,6 +1875,10 @@
|
||||
"customCategories": "カスタムカテゴリ",
|
||||
"modelConfiguration": "モデル設定",
|
||||
"fillRecommended": "推奨を入力",
|
||||
"fillRecommendedSuccess": "推奨モデル {{count}} 件を設定しました",
|
||||
"fillRecommendedPartial": "推奨モデル {{filled}} 件を設定、{{unmatched}} 件は未一致",
|
||||
"fillRecommendedAllSet": "すべてのスロットにモデルが設定済みです",
|
||||
"fillRecommendedNoMatch": "推奨モデルが設定済みプロバイダーに見つかりませんでした",
|
||||
"configSummary": "{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開",
|
||||
"enabledModelsCount": "設定済みモデル {{count}} 件",
|
||||
"source": "出典:",
|
||||
@@ -1839,6 +1952,22 @@
|
||||
"unspecifiedLow": "未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。",
|
||||
"unspecifiedHigh": "未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.6 の max バリアントを使用。",
|
||||
"writing": "ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Gemini 3 Flash 高速生成モデルを使用。"
|
||||
},
|
||||
"slimAgentDesc": {
|
||||
"orchestrator": "オーケストレーター",
|
||||
"oracle": "オラクル",
|
||||
"librarian": "ライブラリアン",
|
||||
"explorer": "エクスプローラー",
|
||||
"designer": "デザイナー",
|
||||
"fixer": "フィクサー"
|
||||
},
|
||||
"slimAgentTooltip": {
|
||||
"orchestrator": "実行コードの作成、マルチエージェントワークフローの調整、エキスパートの召喚",
|
||||
"oracle": "根本原因分析、アーキテクチャレビュー、デバッグガイダンス(読み取り専用)",
|
||||
"librarian": "ドキュメント検索、GitHubコード検索(読み取り専用)",
|
||||
"explorer": "正規表現検索、ASTパターンマッチング、ファイル検出(読み取り専用)",
|
||||
"designer": "モダンなレスポンシブデザイン、CSS/Tailwindの専門知識",
|
||||
"fixer": "コード実装、リファクタリング、テスト、検証"
|
||||
}
|
||||
},
|
||||
"openclawConfig": {
|
||||
|
||||
+158
-48
@@ -37,7 +37,8 @@
|
||||
"search": "查询",
|
||||
"reset": "重置",
|
||||
"actions": "操作",
|
||||
"deleting": "删除中..."
|
||||
"deleting": "删除中...",
|
||||
"auto": "自动"
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -77,7 +78,9 @@
|
||||
"tabProvider": "供应商",
|
||||
"tabUniversal": "统一供应商",
|
||||
"noProviders": "还没有添加任何供应商",
|
||||
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
|
||||
"noProvidersDescription": "点击下方的\"导入当前配置\"按钮导入已有配置,或手动添加新的供应商",
|
||||
"importCurrent": "导入当前配置",
|
||||
"importCurrentDescription": "将当前正在使用的配置导入为默认供应商",
|
||||
"currentlyUsing": "当前使用",
|
||||
"enable": "启用",
|
||||
"inUse": "使用中",
|
||||
@@ -91,8 +94,8 @@
|
||||
"addOpenCodeProvider": "添加 OpenCode 供应商",
|
||||
"addToConfig": "添加",
|
||||
"removeFromConfig": "移除",
|
||||
"setAsDefault": "启用",
|
||||
"isDefault": "默认",
|
||||
"setAsDefault": "设为默认",
|
||||
"isDefault": "当前默认",
|
||||
"inConfig": "已添加",
|
||||
"addProviderHint": "填写信息后即可在列表中快速切换供应商。",
|
||||
"editClaudeProvider": "编辑 Claude Code 供应商",
|
||||
@@ -171,19 +174,31 @@
|
||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||
"openclawDefaultModelSet": "已设为默认模型",
|
||||
"openclawDefaultModelSetFailed": "设置默认模型失败",
|
||||
"openclawNoModels": "该供应商没有配置模型"
|
||||
"openclawNoModels": "该供应商没有配置模型",
|
||||
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。",
|
||||
"removeProvider": "移除供应商",
|
||||
"removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。"
|
||||
"removeProviderMessage": "确定要从配置中移除供应商 \"{{name}}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。",
|
||||
"proxy": {
|
||||
"title": "启用本地代理服务",
|
||||
"message": "本地代理是一项高级功能,启用前请确保您已了解其工作原理。\n\n建议先查阅相关文档或咨询您的供应商,以获取正确的配置方式。",
|
||||
"confirm": "我已了解,继续启用"
|
||||
},
|
||||
"usage": {
|
||||
"title": "配置用量查询",
|
||||
"message": "用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\n\n如不确定如何配置,请先查阅供应商文档。",
|
||||
"confirm": "我已了解,继续配置"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"general": "通用",
|
||||
"tabGeneral": "通用",
|
||||
"tabAdvanced": "高级",
|
||||
"tabProxy": "代理",
|
||||
"advanced": {
|
||||
"configDir": {
|
||||
"title": "配置文件目录",
|
||||
@@ -192,6 +207,8 @@
|
||||
"proxy": {
|
||||
"title": "本地代理",
|
||||
"description": "控制代理服务开关、查看状态与端口信息",
|
||||
"enableFeature": "在主页面显示本地代理开关",
|
||||
"enableFeatureDescription": "开启后,主页面顶部将显示代理和故障转移开关",
|
||||
"running": "运行中",
|
||||
"stopped": "已停止"
|
||||
},
|
||||
@@ -213,7 +230,15 @@
|
||||
},
|
||||
"data": {
|
||||
"title": "数据管理",
|
||||
"description": "导入导出配置与备份恢复"
|
||||
"description": "导入和导出本地配置数据"
|
||||
},
|
||||
"backup": {
|
||||
"title": "备份与恢复",
|
||||
"description": "管理自动备份,查看和恢复数据库快照"
|
||||
},
|
||||
"cloudSync": {
|
||||
"title": "云同步",
|
||||
"description": "通过 WebDAV 在多设备间同步数据"
|
||||
},
|
||||
"rectifier": {
|
||||
"title": "整流器",
|
||||
@@ -275,6 +300,37 @@
|
||||
"selectFileFailed": "请选择有效的 SQL 备份文件",
|
||||
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
|
||||
"backupId": "备份ID",
|
||||
"backupManager": {
|
||||
"title": "数据库备份",
|
||||
"description": "自动备份的数据库快照,可用于恢复到之前的状态",
|
||||
"empty": "暂无备份",
|
||||
"restore": "恢复",
|
||||
"restoring": "恢复中...",
|
||||
"confirmTitle": "确认恢复备份",
|
||||
"confirmMessage": "恢复到此备份将覆盖当前数据库。恢复前会自动创建安全备份。",
|
||||
"restoreSuccess": "恢复成功!安全备份已创建",
|
||||
"restoreFailed": "恢复失败",
|
||||
"safetyBackupId": "安全备份ID",
|
||||
"intervalLabel": "自动备份间隔",
|
||||
"retainLabel": "备份保留数量",
|
||||
"intervalDisabled": "禁用",
|
||||
"intervalHours": "{{hours}} 小时",
|
||||
"intervalDays": "{{days}} 天",
|
||||
"rename": "重命名",
|
||||
"renameSuccess": "备份重命名成功",
|
||||
"renameFailed": "重命名失败",
|
||||
"namePlaceholder": "输入新名称",
|
||||
"createBackup": "立即备份",
|
||||
"creating": "备份中...",
|
||||
"createSuccess": "备份创建成功",
|
||||
"createFailed": "备份创建失败",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中...",
|
||||
"deleteSuccess": "备份已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"deleteConfirmTitle": "确认删除备份",
|
||||
"deleteConfirmMessage": "此备份将被永久删除,此操作无法撤消。"
|
||||
},
|
||||
"webdavSync": {
|
||||
"title": "WebDAV 云同步",
|
||||
"description": "通过 WebDAV 在多设备间同步数据库和技能配置。",
|
||||
@@ -448,6 +504,14 @@
|
||||
"oneClickInstall": "一键安装",
|
||||
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "本地环境检查",
|
||||
"envBadge": {
|
||||
"wsl": "WSL",
|
||||
"windows": "Win",
|
||||
"macos": "macOS",
|
||||
"linux": "Linux"
|
||||
},
|
||||
"wslShell": "Shell",
|
||||
"wslShellFlag": "标志",
|
||||
"installCommandsCopied": "安装命令已复制",
|
||||
"installCommandsCopyFailed": "复制失败,请手动复制。",
|
||||
"importFailedError": "导入配置失败:{{message}}",
|
||||
@@ -497,7 +561,7 @@
|
||||
},
|
||||
"sessionManager": {
|
||||
"title": "会话管理",
|
||||
"subtitle": "管理 Codex 与 Claude Code 会话记录",
|
||||
"subtitle": "管理 Claude Code、Codex、OpenCode、OpenClaw 与 Gemini CLI 会话记录",
|
||||
"searchPlaceholder": "搜索会话内容、目录或 ID",
|
||||
"searchSessions": "搜索会话",
|
||||
"providerFilterAll": "全部",
|
||||
@@ -583,7 +647,10 @@
|
||||
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
|
||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
|
||||
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!",
|
||||
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!"
|
||||
"aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!",
|
||||
"aicoding": "AI Coding 为 CC Switch 的用户提供了特殊优惠,首次充值可以享受 9 折优惠!",
|
||||
"crazyrouter": "CrazyRouter 为 CC Switch 的用户提供了特殊优惠,首次充值赠予 30% 额外额度!",
|
||||
"sssaicode": "SSAI Code 为 CC Switch 的用户提供了特殊优惠,每次充值赠予额外 $10 额度!"
|
||||
},
|
||||
"parameterConfig": "参数配置 - {{name}} *",
|
||||
"mainModel": "主模型 (可选)",
|
||||
@@ -746,38 +813,6 @@
|
||||
"modelOptionKeyPlaceholder": "provider",
|
||||
"modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}"
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
"apiProtocol": "API 协议",
|
||||
"selectProtocol": "选择 API 协议",
|
||||
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
"baseUrl": "API 端点",
|
||||
"baseUrlHint": "供应商的 API 端点地址。",
|
||||
"models": "模型列表",
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "显示名称",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "上下文窗口",
|
||||
"maxTokens": "最大输出 Tokens",
|
||||
"reasoning": "推理模式",
|
||||
"reasoningOn": "启用",
|
||||
"reasoningOff": "关闭",
|
||||
"inputCost": "输入价格 ($/M tokens)",
|
||||
"outputCost": "输出价格 ($/M tokens)",
|
||||
"advancedOptions": "高级选项",
|
||||
"cacheReadCost": "缓存读取价格 ($/M tokens)",
|
||||
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
|
||||
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。"
|
||||
},
|
||||
"providerPreset": {
|
||||
"label": "预设供应商",
|
||||
"custom": "自定义配置",
|
||||
@@ -1164,9 +1199,61 @@
|
||||
"editing": "编辑 {{filename}}",
|
||||
"saveSuccess": "保存成功",
|
||||
"saveFailed": "保存失败",
|
||||
"loadFailed": "读取失败"
|
||||
"loadFailed": "读取失败",
|
||||
"openDirectory": "在文件管理器中打开",
|
||||
"dailyMemory": {
|
||||
"title": "每日记忆",
|
||||
"sectionTitle": "每日记忆",
|
||||
"cardTitle": "每日记忆文件",
|
||||
"cardDescription": "浏览管理每日记忆",
|
||||
"createToday": "添加记忆",
|
||||
"empty": "暂无每日记忆文件",
|
||||
"loadFailed": "加载每日记忆文件失败",
|
||||
"createFailed": "创建每日记忆文件失败",
|
||||
"deleteSuccess": "每日记忆文件已删除",
|
||||
"deleteFailed": "删除每日记忆文件失败",
|
||||
"confirmDeleteTitle": "删除每日记忆",
|
||||
"confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。",
|
||||
"searchPlaceholder": "搜索全文内容...",
|
||||
"searchScopeHint": "全文搜索所有每日记忆 ⌘F",
|
||||
"searchCloseHint": "Esc 关闭",
|
||||
"noSearchResults": "没有找到匹配的每日记忆。",
|
||||
"searching": "搜索中...",
|
||||
"searchFailed": "搜索失败",
|
||||
"matchCount": "{{count}} 处匹配"
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"providerKey": "供应商标识",
|
||||
"providerKeyPlaceholder": "my-provider",
|
||||
"providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符",
|
||||
"providerKeyRequired": "请填写供应商标识",
|
||||
"providerKeyDuplicate": "此标识已被使用,请更换",
|
||||
"providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符",
|
||||
"apiProtocol": "API 协议",
|
||||
"selectProtocol": "选择 API 协议",
|
||||
"apiProtocolHint": "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。",
|
||||
"baseUrl": "API 端点",
|
||||
"baseUrlHint": "供应商的 API 端点地址。",
|
||||
"models": "模型列表",
|
||||
"addModel": "添加模型",
|
||||
"noModels": "暂无模型配置。点击添加模型来配置可用模型。",
|
||||
"modelId": "模型 ID",
|
||||
"modelIdPlaceholder": "claude-3-sonnet",
|
||||
"modelName": "显示名称",
|
||||
"modelNamePlaceholder": "Claude 3 Sonnet",
|
||||
"contextWindow": "上下文窗口",
|
||||
"maxTokens": "最大输出 Tokens",
|
||||
"reasoning": "推理模式",
|
||||
"reasoningOn": "启用",
|
||||
"reasoningOff": "关闭",
|
||||
"inputCost": "输入价格 ($/M tokens)",
|
||||
"outputCost": "输出价格 ($/M tokens)",
|
||||
"advancedOptions": "高级选项",
|
||||
"cacheReadCost": "缓存读取价格 ($/M tokens)",
|
||||
"cacheWriteCost": "缓存写入价格 ($/M tokens)",
|
||||
"cacheCostHint": "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。",
|
||||
"modelsHint": "配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。",
|
||||
"env": {
|
||||
"title": "环境变量",
|
||||
"description": "管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)",
|
||||
@@ -1202,7 +1289,8 @@
|
||||
"description": "管理 openclaw.json 中的 agents.defaults 配置(默认模型、运行参数等)",
|
||||
"modelSection": "模型配置",
|
||||
"primaryModel": "主模型",
|
||||
"primaryModelHint": "格式:provider/model-id",
|
||||
"primaryModelHint": "在供应商列表中通过「设为默认模型」按钮设置",
|
||||
"notSet": "未设置",
|
||||
"fallbackModels": "回退模型",
|
||||
"fallbackModelsHint": "逗号分隔,按优先级排列",
|
||||
"runtimeSection": "运行参数",
|
||||
@@ -1494,7 +1582,7 @@
|
||||
"currentProvider": "当前 Provider:",
|
||||
"waitingFirstRequest": "当前 Provider:等待首次请求…",
|
||||
"stoppedTitle": "代理服务已停止",
|
||||
"stoppedDescription": "使用右上角开关即可启动服务",
|
||||
"stoppedDescription": "使用上方开关即可启动服务",
|
||||
"openSettings": "配置代理服务",
|
||||
"stats": {
|
||||
"activeConnections": "活跃连接",
|
||||
@@ -1585,6 +1673,12 @@
|
||||
"restartRequired": "修改地址或端口后需要重启代理服务才能生效"
|
||||
},
|
||||
"switchFailed": "切换失败: {{error}}",
|
||||
"takeover": {
|
||||
"hint": "选择要接管的应用,启用后该应用的请求将通过本地代理转发",
|
||||
"enabled": "{{app}} 接管已启用",
|
||||
"disabled": "{{app}} 接管已关闭",
|
||||
"failed": "切换接管状态失败"
|
||||
},
|
||||
"failover": {
|
||||
"proxyRequired": "需要先启动代理服务才能配置故障转移",
|
||||
"autoSwitch": "自动故障转移",
|
||||
@@ -1752,10 +1846,6 @@
|
||||
"enabled": "启用中",
|
||||
"disabled": "OMO 已停用",
|
||||
"disableFailed": "停用 OMO 失败: {{error}}",
|
||||
"writeCommonConfig": "写入通用配置",
|
||||
"editCommonConfig": "编辑通用配置",
|
||||
"editCommonConfigTitle": "通用配置",
|
||||
"commonConfigHint": "OMO 通用配置将合并到所有启用它的 OMO 配置中",
|
||||
"selectPlaceholder": "请选择...",
|
||||
"clear": "清空",
|
||||
"clearWrapped": "(清空)",
|
||||
@@ -1785,6 +1875,10 @@
|
||||
"customCategories": "自定义分类",
|
||||
"modelConfiguration": "模型配置",
|
||||
"fillRecommended": "填充推荐",
|
||||
"fillRecommendedSuccess": "已填充 {{count}} 个推荐模型",
|
||||
"fillRecommendedPartial": "已填充 {{filled}} 个推荐模型,{{unmatched}} 个未匹配",
|
||||
"fillRecommendedAllSet": "所有槽位已有模型配置",
|
||||
"fillRecommendedNoMatch": "推荐模型在已配置的供应商中未找到匹配",
|
||||
"configSummary": "已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数",
|
||||
"enabledModelsCount": "可选已配置模型 {{count}} 个",
|
||||
"source": "来源:",
|
||||
@@ -1858,6 +1952,22 @@
|
||||
"unspecifiedLow": "未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。",
|
||||
"unspecifiedHigh": "未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.6 的最大变体。",
|
||||
"writing": "写作类别,专注于文档、散文和技术写作,默认使用 Gemini 3 Flash 快速生成模型。"
|
||||
},
|
||||
"slimAgentDesc": {
|
||||
"orchestrator": "编排者",
|
||||
"oracle": "神谕者",
|
||||
"librarian": "图书管理员",
|
||||
"explorer": "探索者",
|
||||
"designer": "设计师",
|
||||
"fixer": "修复者"
|
||||
},
|
||||
"slimAgentTooltip": {
|
||||
"orchestrator": "编写执行代码,编排多代理工作流,召唤专家",
|
||||
"oracle": "根本原因分析、架构审查、调试指导(只读)",
|
||||
"librarian": "文档查询、GitHub 代码搜索(只读)",
|
||||
"explorer": "正则搜索、AST 模式匹配、文件发现(只读)",
|
||||
"designer": "现代响应式设计、CSS/Tailwind 精通",
|
||||
"fixer": "代码实现、重构、测试、验证"
|
||||
}
|
||||
},
|
||||
"openclawConfig": {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||
<svg width="470" height="470" viewBox="0 0 470 470" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group">
|
||||
<path id="Path" fill="#a78bfa" stroke="none" d="M 33 73 L 137 13 L 263 83 L 159 143 Z"/>
|
||||
<path id="path1" fill="#a78bfa" stroke="none" opacity="0.92" d="M 33 73 L 33 213 L 159 283 L 159 143 Z"/>
|
||||
<path id="path2" fill="#ffffff" stroke="none" d="M 207 247 L 311 187 L 431 257 L 327 317 Z"/>
|
||||
<path id="path3" fill="#a78bfa" stroke="none" opacity="0.92" d="M 207 247 L 207 387 L 327 457 L 327 317 Z"/>
|
||||
<path id="path4" fill="#fdba74" stroke="none" d="M 327 317 L 431 257 L 431 397 L 327 457 Z"/>
|
||||
<path id="path5" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 33 73 L 137 13 L 263 83 L 159 143 L 33 73"/>
|
||||
<path id="path6" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 33 73 L 33 213 L 159 283 L 159 143"/>
|
||||
<path id="path7" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 159 143 L 263 83 L 263 223"/>
|
||||
<path id="path8" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 207 247 L 311 187 L 431 257 L 327 317 L 207 247"/>
|
||||
<path id="path9" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 207 247 L 207 387 L 327 457 L 327 317"/>
|
||||
<path id="path10" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 327 317 L 431 257 L 431 397 L 327 457"/>
|
||||
<path id="path11" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 245 163 L 365 163"/>
|
||||
<path id="path12" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 175 197 L 335 117"/>
|
||||
<path id="path13" fill="none" stroke="#2b1b4b" stroke-width="22" stroke-linecap="round" stroke-linejoin="round" d="M 365 163 L 365 241"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user