mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/cc-switch.deb
|
||||||
flatpak-build/
|
flatpak-build/
|
||||||
flatpak-repo/
|
flatpak-repo/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
+178
-1
@@ -7,9 +7,186 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.11.0] - 2026-02-26
|
||||||
|
|
||||||
|
### Feature Release
|
||||||
|
|
||||||
|
This release introduces **OpenClaw** as the fifth supported application, a full **Session Manager** for browsing conversation history across all apps, an independent **Backup Management** panel, **Oh My OpenCode (OMO)** integration, and 50+ other features, fixes, and improvements across 147 commits.
|
||||||
|
|
||||||
|
**Stats**: 147 commits | 274 files changed | +32,179 insertions | -5,467 deletions
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### OpenClaw Support (New Application)
|
||||||
|
|
||||||
|
- **OpenClaw Integration**: Full management support for OpenClaw as the fifth application in CC Switch, including provider switching, configuration panels (Env / Tools / Agents Defaults), Workspace file management (HEARTBEAT / BOOTSTRAP / BOOT), daily memory files, and additive overlay mode
|
||||||
|
- **OpenClaw Provider Presets**: 13+ built-in provider presets with brand icon and complete i18n (zh/en/ja)
|
||||||
|
- **OpenClaw Form Fields**: Dedicated provider form with providerKey input, model allowlist auto-registration, and default model button
|
||||||
|
- **OpenClaw Config Panels**: Env editor, Tools editor, and Agents Defaults editor backed by JSON5 read/write (`openclaw_config.rs`)
|
||||||
|
|
||||||
|
#### Session Manager
|
||||||
|
|
||||||
|
- **Session Manager**: Browse and search conversation history for Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw with table-of-contents navigation and in-session search
|
||||||
|
- **Session App Filter**: Auto-filter sessions by current app when entering the session page
|
||||||
|
- **Session Performance**: Parallel directory scanning and head-tail JSONL reading for faster session list loading
|
||||||
|
|
||||||
|
#### Backup Management
|
||||||
|
|
||||||
|
- **Backup Panel**: Independent backup management panel with configurable backup policy (max count, auto-cleanup) and backup rename support
|
||||||
|
- **Periodic Backup**: Hourly automatic backup timer during runtime
|
||||||
|
- **Pre-Migration Backup**: Automatic backup before database schema migrations with backfill warning
|
||||||
|
- **Delete Backup**: Delete individual backup files with confirmation dialog
|
||||||
|
- **Backup Time Fix**: Use local time instead of UTC for backup file names
|
||||||
|
|
||||||
|
#### Oh My OpenCode (OMO)
|
||||||
|
|
||||||
|
- **OMO Integration**: Full Oh My OpenCode config file management with agent model selection, category configuration, and recommended model fill
|
||||||
|
- **OMO Slim**: Lightweight oh-my-opencode-slim mode support with OmoVariant parameterization
|
||||||
|
- **OMO Cross-Exclusion**: Enforce OMO ↔ OMO Slim mutual exclusion at the database level
|
||||||
|
|
||||||
|
#### Workspace
|
||||||
|
|
||||||
|
- **Daily Memory Search**: Full-text search across daily memory files with date-sorted display
|
||||||
|
- **Clickable Paths**: Directory paths in workspace panels are now clickable; renamed “Today's Note” to “Add Memory”
|
||||||
|
- **Workspace Files Panel**: Manage bootstrap markdown files for OpenClaw (HEARTBEAT / BOOTSTRAP / BOOT types)
|
||||||
|
|
||||||
|
#### Provider Presets
|
||||||
|
|
||||||
|
- **AWS Bedrock**: Support for AKSK and API Key authentication modes (Claude and OpenCode)
|
||||||
|
- **SSAI Code**: Partner provider preset across all five apps
|
||||||
|
- **CrazyRouter**: Partner provider preset with custom icon
|
||||||
|
- **AICoding**: Partner provider preset with i18n promotion text
|
||||||
|
- **Bailian**: Renamed from Qwen Coder with new icon; updated domestic model providers to latest versions
|
||||||
|
|
||||||
|
#### Proxy & Network
|
||||||
|
|
||||||
|
- **Thinking Budget Rectifier**: New rectifier for thinking budget parameters with dedicated module (`thinking_budget_rectifier.rs`)
|
||||||
|
- **WebDAV Auto Sync**: Automatic periodic sync with large file protection mechanism
|
||||||
|
|
||||||
|
#### UI & UX
|
||||||
|
|
||||||
|
- **Theme Animation**: Circular reveal animation when toggling between light and dark themes
|
||||||
|
- **Claude Quick Toggles**: Quick toggle switches in the Claude config JSON editor for common settings
|
||||||
|
- **Dynamic Endpoint Hint**: Context-aware hint text in endpoint input based on API format selection
|
||||||
|
- **AppSwitcher Auto Compact**: Automatically collapse to compact mode based on available width, with smooth transition animation
|
||||||
|
- **App Transition**: Fade-in/fade-out animation when switching between OpenClaw and other apps
|
||||||
|
- **Silent Startup Conditional**: Show silent startup option only when launch-on-startup is enabled
|
||||||
|
|
||||||
|
#### Settings & Environment
|
||||||
|
|
||||||
|
- **First-Run Confirmation**: Confirmation dialogs for proxy and usage features on first use
|
||||||
|
- **Local Proxy Toggle**: `enableLocalProxy` setting to control proxy UI visibility on the home page
|
||||||
|
- **Environment Check**: More granular local environment detection (installed CLI tool versions, Volta path detection)
|
||||||
|
|
||||||
|
#### Usage & Pricing
|
||||||
|
|
||||||
|
- **Usage Dashboard Enhancement**: Auto-refresh control, robust formatting, and request log table improvements
|
||||||
|
- **New Model Pricing**: Added pricing data for claude-opus-4-6 and gpt-5.3-codex with incremental data seeding
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
- **Partial Key-Field Merging**: Provider switching now uses partial key-field merging instead of full config overwrite, preserving user's non-provider settings (plugins, MCP, permissions). Removes 6 frontend files and ~150 lines of backend dead code (#1098)
|
||||||
|
- **Manual Import**: Replaced auto-import on startup with manual “Import Current Config” button in empty state, reducing ~47 lines of startup code
|
||||||
|
- **OMO Variant Parameterization**: Eliminated ~250 lines of OMO/OMO Slim code duplication via `OmoVariant` struct with STANDARD/SLIM constants
|
||||||
|
- **OMO Common Config Removal**: Removed the two-layer merge system for OMO common config (-1,733 lines across 21 files)
|
||||||
|
|
||||||
|
#### Code Quality
|
||||||
|
|
||||||
|
- **ProviderForm Decomposition**: Extracted ProviderForm.tsx from 2,227 lines to 1,526 lines by splitting into 5 focused modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState)
|
||||||
|
- **Shared MCP/Skills Components**: Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to eliminate duplication across MCP and Skills panels
|
||||||
|
- **OpenClaw TanStack Query Migration**: Migrated Env, Tools, and AgentsDefaults panels from manual useState/useEffect to centralized TanStack Query hooks
|
||||||
|
|
||||||
|
#### Settings Layout
|
||||||
|
|
||||||
|
- **Proxy Tab**: Split Advanced tab into dedicated Proxy tab (local proxy, failover, rectifiers, global outbound proxy); moved pricing config to Usage dashboard as collapsible accordion. SettingsPage reduced from ~716 to ~426 lines with 5-tab layout: General | Proxy | Advanced | Usage | About
|
||||||
|
- **Data Section Split**: Split data accordion into Import/Export and Cloud Sync sections for better discoverability
|
||||||
|
|
||||||
|
#### Terminal & Config
|
||||||
|
|
||||||
|
- **Unified Terminal Selection**: Consolidated terminal preference to global settings; added WezTerm support and terminal name mapping (iterm2 → iterm)
|
||||||
|
- **OpenClaw Agents Panel**: Primary model field set to read-only; detailed model fields (context window, max tokens, reasoning, cost) moved to advanced options
|
||||||
|
- **Claude Model Update**: Updated Claude model references from 4.5 to 4.6 across all provider presets
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Windows Home Dir Regression**: Prevent providers/settings “disappearing” after upgrading from v3.10.2 → v3.10.3 when `HOME` differs from the real user profile directory; restore default path resolution and auto-detect the v3.10.3 legacy database location.
|
#### Critical
|
||||||
|
|
||||||
|
- **Windows Home Dir Regression**: Restored default home directory resolution on Windows to prevent providers/settings “disappearing” when `HOME` env var differs from the real user profile directory (Git/MSYS environments); auto-detects v3.10.3 legacy database location
|
||||||
|
- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent EGL initialization failure causing blank screen on startup
|
||||||
|
- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to OpenAI Chat Completions endpoints, fixing request failures for Nvidia and other `apiFormat=”openai_chat”` providers
|
||||||
|
- **Health Check Auth Mode**: Health check now respects provider's auth_mode setting instead of always using x-api-key header
|
||||||
|
|
||||||
|
#### Provider & Preset
|
||||||
|
|
||||||
|
- **OpenClaw /v1 Prefix**: Removed /v1 prefix from OpenClaw anthropic-messages presets to prevent double path (/v1/v1/messages) with Anthropic SDK auto-append
|
||||||
|
- **Opus Pricing**: Corrected Opus pricing from $15/$75 to $5/$25 and upgraded model ID to claude-opus-4-6
|
||||||
|
- **AIGoCode URLs**: Unified API base URL to https://api.aigocode.com across all apps; removed trailing /v1 suffix
|
||||||
|
- **Zhipu GLM**: Removed outdated partner status from Claude, OpenCode, and OpenClaw presets
|
||||||
|
- **API Key Visibility**: Restored API Key input field when creating new Claude providers (was incorrectly hidden for non-cloud_provider categories)
|
||||||
|
|
||||||
|
#### OMO / OMO Slim
|
||||||
|
|
||||||
|
- **OMO Slim Category Checks**: Added missing omo-slim category checks across add/form/mutation paths
|
||||||
|
- **OMO Slim Cache Invalidation**: Invalidate OMO Slim query cache after provider mutations to prevent stale UI state
|
||||||
|
- **OMO Recommended Models**: Synced agent/category recommended models with upstream sources; fixed provider/model format to pure model IDs
|
||||||
|
- **OMO Fill Feedback**: Added toast feedback when “Fill Recommended” button silently fails
|
||||||
|
- **OMO Last-Provider Restriction**: Removed last-provider deletion restriction for OMO/OMO Slim plugins
|
||||||
|
- **OpenCode Model Validation**: Reject saving OpenCode providers without at least one configured model
|
||||||
|
|
||||||
|
#### OpenClaw
|
||||||
|
|
||||||
|
- **OpenClaw P0-P3 Fixes**: Fixed 25 missing i18n keys, replaced key={index} with stable crypto.randomUUID(), excluded openclaw from ProxyToggle/FailoverToggle, added deep link merge_additive_config(), unified serde(flatten) naming, added directory existence checks, removed dead code, added duplicate key validation
|
||||||
|
- **OpenClaw Robustness**: Fixed EnvPanel visibleKeys using entry key names instead of array indices; added NaN guards; validated provider ID and model before import
|
||||||
|
- **OpenClaw i18n Dedup**: Merged duplicate openclaw i18n keys to restore provider form translations
|
||||||
|
|
||||||
|
#### Platform
|
||||||
|
|
||||||
|
- **Window Flash**: Prevented window flicker on silent startup (Windows)
|
||||||
|
- **Title Bar Theme**: Title bar now follows dark/light mode theme changes
|
||||||
|
- **Skills Path Separator**: Fixed path separator matching for skill installation status on Windows (supports both `/` and `\`)
|
||||||
|
- **WSL Conditional Compilation**: Added `#[cfg(target_os = “windows”)]` to WSL helper functions to eliminate dead_code warnings on non-Windows platforms
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
- **Toolbar Clipping**: Removed toolbar height limit that was clipping AppSwitcher
|
||||||
|
- **Update Badge**: Show update badge instead of green check when a newer version is available
|
||||||
|
- **Session Button Visibility**: Only show Session Manager button for Claude and Codex apps
|
||||||
|
- **Directory Spacing**: Added vertical spacing between directory setting sections
|
||||||
|
- **Dark Mode Cards**: Unified SQL import/export card styling in dark mode
|
||||||
|
- **OpenClaw Scroll**: Enabled scrolling for OpenClaw configuration panel content
|
||||||
|
|
||||||
|
#### i18n & Localization
|
||||||
|
|
||||||
|
- **Session Manager i18n**: Replaced hardcoded Chinese strings with i18n keys for relative time, role labels, and UI elements
|
||||||
|
- **OpenClaw Default Model Label**: Renamed “Enable/Default” to “Set as Default / Current Default” with wider button
|
||||||
|
- **Daily Memory Sort**: Sort daily memory files by filename date (YYYY-MM-DD.md) instead of modification time
|
||||||
|
- **Backup Name i18n**: Use local time for backup file names
|
||||||
|
|
||||||
|
#### Other
|
||||||
|
|
||||||
|
- **Skill Doc URL**: Use actual branch from download_repo for documentation URL; switched from /tree/ to /blob/ pointing to SKILL.md
|
||||||
|
- **OpenCode Install Detection**: Added install.sh priority paths (OPENCODE_INSTALL_DIR > XDG_BIN_DIR > ~/bin > ~/.opencode/bin) with path dedup and cross-platform executable candidates
|
||||||
|
- **Provider Auto-Import**: Removed auto-import side effect from useProvidersQuery queryFn; users now trigger import manually via empty state button
|
||||||
|
- **Manual Backup Validation**: Treat missing database file as error during manual backup to prevent false success toast
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Session Panel Loading**: Parallel directory scanning and head-tail JSONL reading for Codex, OpenClaw, and OpenCode session providers
|
||||||
|
- **Query Cache Cleanup**: Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **Sponsors**: Added/updated SSSAiCode, Crazyrouter, AICoding, Right Code, and MiniMax sponsor entries across all README languages
|
||||||
|
- **User Manual**: Added user manual documentation (#979)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- **Pre-Release Cleanup**: Removed debug logs, fixed clippy warnings, added missing Japanese translations, and formatted code
|
||||||
|
- **UI Exclusions**: Hidden MCP, Skills, proxy/pricing, stream check, and model test panels for OpenClaw where not applicable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
|||||||
**Core Capabilities**
|
**Core Capabilities**
|
||||||
|
|
||||||
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
|
- **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
|
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
|
||||||
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
|
||||||
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
|
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
|||||||
**コア機能**
|
**コア機能**
|
||||||
|
|
||||||
- **プロバイダ管理**:Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
|
- **プロバイダ管理**:Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
|
||||||
|
- **AWS Bedrock 対応**:AWS Bedrock プロバイダプリセットを内蔵、AKSK および API Key 認証に対応、クロスリージョン推論(global/us/eu/apac)をサポート、Claude Code と OpenCode に対応
|
||||||
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
|
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
|
||||||
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
|
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
|
||||||
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
|
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
|||||||
**核心功能**
|
**核心功能**
|
||||||
|
|
||||||
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
|
||||||
|
- **AWS Bedrock 支持**:内置 AWS Bedrock 供应商预设,支持 AKSK 和 API Key 两种认证方式,支持跨区域推理(global/us/eu/apac),覆盖 Claude Code 和 OpenCode
|
||||||
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
|
||||||
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
|
||||||
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
|
- **国际化支持**:完整的中英文本地化(UI、错误、托盘)
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# CC Switch v3.11.0
|
||||||
|
|
||||||
|
> OpenClaw Support, Session Manager, Backup Management & 50+ Improvements
|
||||||
|
|
||||||
|
**[中文版 →](release-note-v3.11.0-zh.md) | [日本語版 →](release-note-v3.11.0-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.11.0 is a major update that adds full management support for **OpenClaw** as the fifth application, introduces a new **Session Manager** and **Backup Management** feature. Additionally, **Oh My OpenCode (OMO) integration**, the **partial key-field merging** architecture upgrade for provider switching, **settings page refactoring**, and many other improvements make the overall experience more polished.
|
||||||
|
|
||||||
|
**Release Date**: 2026-02-26
|
||||||
|
|
||||||
|
**Update Scale**: 147 commits | 274 files changed | +32,179 / -5,467 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **OpenClaw Support**: Fifth managed application with 13 provider presets, Env/Tools/AgentsDefaults config editors, and Workspace file management
|
||||||
|
- **Session Manager**: Browse conversation history across all five apps with table-of-contents navigation and in-session search
|
||||||
|
- **Backup Management**: Independent backup panel with configurable policies, periodic backups, and pre-migration auto-backup
|
||||||
|
- **Oh My OpenCode Integration**: Full OMO config management with OMO Slim lightweight mode support
|
||||||
|
- **Partial Key-Field Merging**: Provider switching now preserves user's non-provider settings
|
||||||
|
- **Settings Page Refactoring**: 5-tab layout with ~40% code reduction
|
||||||
|
- **6 New Provider Presets**: AWS Bedrock, SSAI Code, CrazyRouter, AICoding, and more
|
||||||
|
- **Thinking Budget Rectifier**: Fine-grained thinking budget control
|
||||||
|
- **Theme Switch Animation**: Circular reveal transition animation
|
||||||
|
- **WebDAV Auto Sync**: Automatic sync with large file protection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
### OpenClaw Support (New Fifth App)
|
||||||
|
|
||||||
|
Full management support for OpenClaw, the fifth managed application following Claude Code, Codex, Gemini CLI, and OpenCode.
|
||||||
|
|
||||||
|
- **Provider Management**: Add, edit, switch, and delete OpenClaw providers with 13 built-in presets
|
||||||
|
- **Config Editors**: Three dedicated panels for Env (environment variables), Tools, and AgentsDefaults
|
||||||
|
- **Workspace Panel**: HEARTBEAT/BOOTSTRAP/BOOT file management and daily memory
|
||||||
|
- **Additive Overlay Mode**: Support config overlay instead of overwrite
|
||||||
|
- **Default Model Button**: One-click to fill recommended models; auto-register suggested models to allowlist when adding providers
|
||||||
|
- **Brand & Interaction**: Dedicated brand icon, fade-in/fade-out transition animation when switching apps
|
||||||
|
- **Deep Link Support**: Import OpenClaw provider configurations via URL
|
||||||
|
- **Full Internationalization**: Complete Chinese/English/Japanese support
|
||||||
|
|
||||||
|
### Session Manager
|
||||||
|
|
||||||
|
A brand-new session manager to browse and search conversation history.
|
||||||
|
|
||||||
|
- Browse conversation history across Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw (#867, thanks @TinsFox)
|
||||||
|
- Table-of-contents navigation and in-session search
|
||||||
|
- Auto-filter by current app when entering the session page
|
||||||
|
- Parallel directory scanning + head-tail JSONL reading for optimized loading performance
|
||||||
|
|
||||||
|
### Backup Management
|
||||||
|
|
||||||
|
An independent backup management panel for better data safety.
|
||||||
|
|
||||||
|
- Configurable backup policy: maximum backup count and auto-cleanup rules
|
||||||
|
- Hourly automatic backup timer during runtime
|
||||||
|
- Auto-backup before database schema migrations with backfill warning
|
||||||
|
- Support backup rename and deletion (with confirmation dialog)
|
||||||
|
- Backup filenames use local time for better clarity
|
||||||
|
|
||||||
|
### Oh My OpenCode (OMO) Integration
|
||||||
|
|
||||||
|
Full Oh My OpenCode config file management.
|
||||||
|
|
||||||
|
- Agent model selection, category configuration, and recommended model fill (#972, thanks @yovinchen)
|
||||||
|
- Improved agent model selection UX with lowercase key fix (#1004, thanks @yovinchen)
|
||||||
|
- OMO Slim lightweight mode support
|
||||||
|
- OMO ↔ OMO Slim mutual exclusion (enforced at database level)
|
||||||
|
|
||||||
|
### Workspace
|
||||||
|
|
||||||
|
- Full-text search across daily memory files, sorted by date
|
||||||
|
- Clickable directory paths for quick file location access
|
||||||
|
|
||||||
|
### Toolbar
|
||||||
|
|
||||||
|
- AppSwitcher auto-collapses to compact mode based on available width
|
||||||
|
- Smooth transition animation for compact mode toggle
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- First-use confirmation dialogs for proxy and usage features to prevent accidental operations
|
||||||
|
- New `enableLocalProxy` switch to control proxy UI visibility on home page
|
||||||
|
- More granular local environment checks: CLI tool version detection (#870, thanks @kv-chiu), Volta path detection (#969, thanks @myjustify)
|
||||||
|
|
||||||
|
### Provider Presets
|
||||||
|
|
||||||
|
- **AWS Bedrock**: Support for AKSK and API Key authentication modes (#1047, thanks @keithyt06)
|
||||||
|
- **SSAI Code**: Partner preset across all five apps
|
||||||
|
- **CrazyRouter**: Partner preset with dedicated icon
|
||||||
|
- **AICoding**: Partner preset with i18n promotion text
|
||||||
|
- Updated domestic model provider presets to latest versions
|
||||||
|
- Renamed Qwen Coder to Bailian (#965, thanks @zhu-jl18)
|
||||||
|
|
||||||
|
### Other New Features
|
||||||
|
|
||||||
|
- **Thinking Budget Rectifier**: Fine-grained thinking budget allocation control (#1005, thanks @yovinchen)
|
||||||
|
- **WebDAV Auto Sync**: Automatic sync with large file protection (#923, thanks @clx20000410; #1043, thanks @SaladDay)
|
||||||
|
- **Theme Switch Animation**: Circular reveal transition for a smoother visual experience (#905, thanks @funnytime75)
|
||||||
|
- **Claude Config Editor Quick Toggles**: Quick toggle switches for common settings (#1012, thanks @JIA-ss)
|
||||||
|
- **Dynamic Endpoint Hint**: Context-aware hint text based on API format selection (#860, thanks @zhu-jl18)
|
||||||
|
- **Usage Dashboard Enhancement**: Auto-refresh control and robust formatting (#942, thanks @yovinchen)
|
||||||
|
- **New Pricing Data**: claude-opus-4-6 and gpt-5.3-codex (#943, thanks @yovinchen)
|
||||||
|
- **Silent Startup Optimization**: Silent startup option only shown when launch-on-startup is enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Improvements
|
||||||
|
|
||||||
|
### Partial Key-Field Merging (Important Change)
|
||||||
|
|
||||||
|
Provider switching now uses partial key-field merging instead of full config overwrite (#1098). When switching providers, only provider-related key-values are updated, preserving user's non-provider settings (plugins, MCP, permissions, etc.). This refactoring removed 6 frontend files and ~150 lines of backend dead code.
|
||||||
|
|
||||||
|
### Manual Import Replaces Auto-Import
|
||||||
|
|
||||||
|
Startup no longer auto-imports external configurations. Users now click "Import Current Config" manually, preventing accidental data overwrites.
|
||||||
|
|
||||||
|
### OmoVariant Parameterization
|
||||||
|
|
||||||
|
Eliminated ~250 lines of duplicated code in the OMO module via `OmoVariant` struct parameterization.
|
||||||
|
|
||||||
|
### OMO Common Config Removal
|
||||||
|
|
||||||
|
Removed the two-layer merge system, reducing ~1,733 lines of code and simplifying the architecture.
|
||||||
|
|
||||||
|
### ProviderForm Decomposition
|
||||||
|
|
||||||
|
Reduced ProviderForm component from 2,227 lines to 1,526 lines by extracting 5 independent modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState), significantly improving maintainability.
|
||||||
|
|
||||||
|
### Shared MCP/Skills Components
|
||||||
|
|
||||||
|
Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to reduce duplication across MCP and Skills panels (#897, thanks @PeanutSplash).
|
||||||
|
|
||||||
|
### Settings Page Refactoring
|
||||||
|
|
||||||
|
Refactored settings page to a 5-tab layout (General | Proxy | Advanced | Usage | About), reducing SettingsPage code from ~716 to ~426 lines.
|
||||||
|
|
||||||
|
### Other Improvements
|
||||||
|
|
||||||
|
- Unified terminal selection via global settings with WezTerm support added
|
||||||
|
- Updated Claude model references from 4.5 to 4.6
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
### Critical Fixes
|
||||||
|
|
||||||
|
- **Windows Home Dir Regression**: Restored default home directory resolution to prevent providers/settings "disappearing" when `HOME` env var differs from the real user profile directory in Git/MSYS environments
|
||||||
|
- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent blank screen on startup (#986, thanks @ThendCN)
|
||||||
|
- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to `/v1/chat/completions` endpoints, fixing request failures for Nvidia and other `apiFormat="openai_chat"` providers (#1052, thanks @jnorthrup)
|
||||||
|
- **Health Check Auth**: Health check now respects provider's `auth_mode` setting, preventing failures for proxy services that only support Bearer authentication (#824, thanks @Jassy930)
|
||||||
|
|
||||||
|
### Provider Preset Fixes
|
||||||
|
|
||||||
|
- Fixed OpenClaw `/v1` prefix causing double path (/v1/v1/messages)
|
||||||
|
- Corrected Opus pricing ($15/$75 → $5/$25) and upgraded to 4.6
|
||||||
|
- Unified AIGoCode URL to `https://api.aigocode.com` across all apps
|
||||||
|
- Removed outdated partner status from Zhipu GLM presets
|
||||||
|
- Restored API Key input visibility when creating new Claude providers
|
||||||
|
- Hide quick toggles for non-active providers, show context-aware JSON editor hints
|
||||||
|
|
||||||
|
### OMO Fixes
|
||||||
|
|
||||||
|
- Added missing omo-slim category checks across add/form/mutation paths
|
||||||
|
- Fixed OMO Slim query cache invalidation after provider mutations
|
||||||
|
- Synced OMO agent/category recommended models with upstream sources
|
||||||
|
- Added toast feedback for "Fill Recommended" button silent failures
|
||||||
|
- Removed last-provider deletion restriction for OMO/OMO Slim
|
||||||
|
- Reject saving OpenCode providers without configured models (#932, thanks @yovinchen)
|
||||||
|
|
||||||
|
### OpenClaw Fixes
|
||||||
|
|
||||||
|
- Fixed 25 missing i18n keys, replaced key={index} with stable IDs, added deep link additive merge, and other code review issues
|
||||||
|
- Enhanced EnvPanel robustness (NaN guards, entry key names instead of array indices)
|
||||||
|
- Merged duplicate i18n keys to restore provider form translations
|
||||||
|
|
||||||
|
### Platform Fixes
|
||||||
|
|
||||||
|
- Windows silent startup window flicker (#901, thanks @funnytime75)
|
||||||
|
- Title bar dark mode theme following (#903, thanks @funnytime75)
|
||||||
|
- Windows Skills path separator matching (#868, thanks @stmoonar)
|
||||||
|
- WSL helper functions conditional compilation
|
||||||
|
|
||||||
|
### UI Fixes
|
||||||
|
|
||||||
|
- Toolbar height clipping causing AppSwitcher to be obscured
|
||||||
|
- Show update badge instead of green checkmark when newer version available
|
||||||
|
- Session Manager button only visible for Claude/Codex apps
|
||||||
|
- Unified SQL import/export card dark mode styling (#1067, thanks @SaladDay)
|
||||||
|
|
||||||
|
### Other Fixes
|
||||||
|
|
||||||
|
- Replaced hardcoded Chinese strings in Session Manager with i18n keys
|
||||||
|
- Fixed Skill documentation URL branch and path resolution (#977, thanks @yovinchen)
|
||||||
|
- Added missing OpenCode install.sh installation path detection (#988, thanks @zhu-jl18)
|
||||||
|
- Fixed Skill ZIP symlink resolution (#1040, thanks @yovinchen)
|
||||||
|
- Added missing OpenCode checkbox in MCP add/edit form (#1026, thanks @yovinchen)
|
||||||
|
- Removed auto-import side effect from useProvidersQuery queryFn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- Parallel directory scanning + head-tail JSONL reading for session panel, significantly improving session list loading speed
|
||||||
|
- Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Sponsor updates: SSSAiCode, Crazyrouter, AICoding, Right Code, MiniMax
|
||||||
|
- Added user manual documentation (#979, thanks @yovinchen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & Considerations
|
||||||
|
|
||||||
|
- **OpenClaw is a newly supported app**: OpenClaw CLI must be installed first to use related features.
|
||||||
|
- **Partial key-field merging is an important architecture change**: Provider switching no longer overwrites the entire config file, but only merges provider-related key-values. Please note this change if you previously relied on full overwrite behavior.
|
||||||
|
- **Auto-import changed to manual**: External configurations are no longer auto-imported on startup. Click "Import Current Config" manually when needed.
|
||||||
|
- **OMO and OMO Slim are mutually exclusive**: Only one can be active at a time. Switching to one automatically disables the other.
|
||||||
|
- **Backup is enabled by default**: Automatic hourly backup during runtime. Adjust the policy in the Backup panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Thanks
|
||||||
|
|
||||||
|
Thanks to all contributors for their contributions to this release!
|
||||||
|
|
||||||
|
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Installation
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
| System | Minimum Version | Architecture |
|
||||||
|
| ------- | ------------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 or later | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | See table below | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| ---------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.11.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||||
|
| `CC-Switch-v3.11.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| -------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.11.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||||
|
| `CC-Switch-v3.11.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||||
|
|
||||||
|
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| Distribution | Recommended Format | Installation Method |
|
||||||
|
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
|
||||||
|
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# CC Switch v3.11.0
|
||||||
|
|
||||||
|
> OpenClaw サポート、セッションマネージャー、バックアップ管理と 50 以上の改善
|
||||||
|
|
||||||
|
**[中文版 →](release-note-v3.11.0-zh.md) | [English →](release-note-v3.11.0-en.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
CC Switch v3.11.0 は大規模なアップデートです。5番目のアプリケーション **OpenClaw** の完全管理サポートを追加し、新しい**セッションマネージャー**と**バックアップ管理**機能を導入しました。さらに、**Oh My OpenCode (OMO) 統合**、プロバイダー切り替えの**部分キーフィールドマージ**アーキテクチャアップグレード、**設定ページのリファクタリング**など、多数の改善により全体的な体験がさらに向上しました。
|
||||||
|
|
||||||
|
**リリース日**: 2026-02-26
|
||||||
|
|
||||||
|
**更新規模**: 147 commits | 274 files changed | +32,179 / -5,467 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ハイライト
|
||||||
|
|
||||||
|
- **OpenClaw サポート**: 5番目の管理対象アプリ、13 のプロバイダープリセット、Env/Tools/AgentsDefaults 設定エディター、Workspace ファイル管理
|
||||||
|
- **セッションマネージャー**: 5つのアプリの会話履歴を閲覧、目次ナビゲーションとセッション内検索
|
||||||
|
- **バックアップ管理**: 独立バックアップパネル、設定可能なポリシー、定期バックアップ、マイグレーション前自動バックアップ
|
||||||
|
- **Oh My OpenCode 統合**: 完全な OMO 設定管理、OMO Slim 軽量モードサポート
|
||||||
|
- **部分キーフィールドマージ**: プロバイダー切り替え時にユーザーの非プロバイダー設定を保持
|
||||||
|
- **設定ページリファクタリング**: 5タブレイアウト、コード量約 40% 削減
|
||||||
|
- **6つの新プロバイダープリセット**: AWS Bedrock、SSAI Code、CrazyRouter、AICoding など
|
||||||
|
- **Thinking Budget Rectifier**: より精密な thinking budget 制御
|
||||||
|
- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション
|
||||||
|
- **WebDAV 自動同期**: 自動同期と大容量ファイル保護
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主な機能
|
||||||
|
|
||||||
|
### OpenClaw サポート(新しい5番目のアプリ)
|
||||||
|
|
||||||
|
Claude Code、Codex、Gemini CLI、OpenCode に続く5番目の管理対象アプリケーションとして OpenClaw の完全管理サポートを追加しました。
|
||||||
|
|
||||||
|
- **プロバイダー管理**: OpenClaw プロバイダーの追加、編集、切り替え、削除、13 の内蔵プリセット
|
||||||
|
- **設定エディター**: Env(環境変数)、Tools(ツール)、AgentsDefaults(エージェントデフォルト)の3つの専用パネル
|
||||||
|
- **Workspace パネル**: HEARTBEAT/BOOTSTRAP/BOOT ファイル管理とデイリーメモリ
|
||||||
|
- **Additive オーバーレイモード**: 上書きではなく設定の重ね合わせをサポート
|
||||||
|
- **デフォルトモデルボタン**: ワンクリックで推奨モデルを入力、プロバイダー追加時に候補モデルを allowlist に自動登録
|
||||||
|
- **ブランドとインタラクション**: 専用ブランドアイコン、アプリ切り替えフェード遷移アニメーション
|
||||||
|
- **ディープリンクサポート**: URL 経由で OpenClaw プロバイダー設定をインポート
|
||||||
|
- **完全な国際化**: 中/英/日 三言語完全対応
|
||||||
|
|
||||||
|
### セッションマネージャー
|
||||||
|
|
||||||
|
会話履歴を閲覧・検索できる新しいセッションマネージャーです。
|
||||||
|
|
||||||
|
- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw の5つのアプリの会話履歴を閲覧(#867、@TinsFox に感謝)
|
||||||
|
- 目次ナビゲーションとセッション内検索
|
||||||
|
- セッションページに入ると現在のアプリで自動フィルター
|
||||||
|
- 並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで読み込みパフォーマンスを最適化
|
||||||
|
|
||||||
|
### バックアップ管理
|
||||||
|
|
||||||
|
データの安全性を高める独立バックアップ管理パネルです。
|
||||||
|
|
||||||
|
- 設定可能なバックアップポリシー: 最大バックアップ数、自動クリーンアップルール
|
||||||
|
- ランタイム中の1時間ごとの定期自動バックアップ
|
||||||
|
- データベースマイグレーション前の自動バックアップ、バックフィル警告プロンプト
|
||||||
|
- バックアップのリネームと削除をサポート(確認ダイアログ付き)
|
||||||
|
- バックアップファイル名にローカルタイムを使用、より直感的に
|
||||||
|
|
||||||
|
### Oh My OpenCode (OMO) 統合
|
||||||
|
|
||||||
|
完全な Oh My OpenCode 設定ファイル管理です。
|
||||||
|
|
||||||
|
- エージェントモデル選択、カテゴリ設定、推奨モデル入力(#972、@yovinchen に感謝)
|
||||||
|
- エージェントモデル選択 UX の改善、lowercase key 問題の修正(#1004、@yovinchen に感謝)
|
||||||
|
- OMO Slim 軽量モードサポート
|
||||||
|
- OMO と OMO Slim の相互排他(データベースレベルで一貫性を保証)
|
||||||
|
|
||||||
|
### ワークスペース
|
||||||
|
|
||||||
|
- デイリーメモリファイルの全文検索、日付順ソート
|
||||||
|
- ディレクトリパスがクリック可能に、ファイル位置をすばやく開く
|
||||||
|
|
||||||
|
### ツールバー
|
||||||
|
|
||||||
|
- AppSwitcher がウィンドウ幅に応じて自動的にコンパクトモードに折りたたみ
|
||||||
|
- コンパクトモード切り替えのスムーズ遷移アニメーション
|
||||||
|
|
||||||
|
### 設定
|
||||||
|
|
||||||
|
- プロキシと使用量機能に初回使用確認ダイアログを追加、誤操作を防止
|
||||||
|
- `enableLocalProxy` スイッチを追加、ホーム画面のプロキシ UI 表示を制御
|
||||||
|
- より詳細なローカル環境チェック: CLI ツールバージョン検出(#870、@kv-chiu に感謝)、Volta パス検出(#969、@myjustify に感謝)
|
||||||
|
|
||||||
|
### プロバイダープリセット
|
||||||
|
|
||||||
|
- **AWS Bedrock**: AKSK と API Key の2種類の認証方式をサポート(#1047、@keithyt06 に感謝)
|
||||||
|
- **SSAI Code**: パートナープリセット、5アプリ対応
|
||||||
|
- **CrazyRouter**: パートナープリセットと専用アイコン
|
||||||
|
- **AICoding**: パートナープリセットとプロモーションテキスト
|
||||||
|
- 国内モデルプロバイダープリセットを最新版に更新
|
||||||
|
- Qwen Coder を百炼 (Bailian) にリネーム(#965、@zhu-jl18 に感謝)
|
||||||
|
|
||||||
|
### その他の新機能
|
||||||
|
|
||||||
|
- **Thinking Budget Rectifier**: より精密な thinking budget 制御(#1005、@yovinchen に感謝)
|
||||||
|
- **WebDAV 自動同期**: 自動同期設定と大容量ファイル保護(#923、@clx20000410 に感謝;#1043、@SaladDay に感謝)
|
||||||
|
- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション(#905、@funnytime75 に感謝)
|
||||||
|
- **Claude 設定エディタークイックトグル**: よく使う設定項目のクイック切り替え(#1012、@JIA-ss に感謝)
|
||||||
|
- **動的エンドポイントヒント**: API フォーマット選択に基づく動的ヒントテキスト(#860、@zhu-jl18 に感謝)
|
||||||
|
- **使用量ダッシュボード強化**: 自動更新、堅牢なフォーマット(#942、@yovinchen に感謝)
|
||||||
|
- **新しい価格データ**: claude-opus-4-6 と gpt-5.3-codex(#943、@yovinchen に感謝)
|
||||||
|
- **サイレント起動の最適化**: サイレント起動オプションは自動起動が有効な場合のみ表示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## アーキテクチャ改善
|
||||||
|
|
||||||
|
### 部分キーフィールドマージ(重要な変更)
|
||||||
|
|
||||||
|
プロバイダー切り替えを完全な設定上書きから部分キーフィールドマージ戦略に変更しました(#1098)。プロバイダー切り替え時に、プロバイダー関連のキー値のみを更新し、ユーザーの非プロバイダー設定(プラグイン設定、MCP 設定、権限設定など)を保持します。このリファクタリングにより、フロントエンドファイル6つと約150行のバックエンドデッドコードを削除しました。
|
||||||
|
|
||||||
|
### 手動インポートに変更
|
||||||
|
|
||||||
|
起動時の自動インポートを廃止し、手動の「現在の設定をインポート」ボタンに変更。意図しないユーザーデータの上書きを防止します。
|
||||||
|
|
||||||
|
### OmoVariant パラメータ化
|
||||||
|
|
||||||
|
`OmoVariant` 構造体によるパラメータ化で、OMO モジュールの約250行の重複コードを削除しました。
|
||||||
|
|
||||||
|
### OMO 共通設定の削除
|
||||||
|
|
||||||
|
2層マージシステムを削除し、約1,733行のコードを削減、アーキテクチャを簡素化しました。
|
||||||
|
|
||||||
|
### ProviderForm 分割
|
||||||
|
|
||||||
|
ProviderForm コンポーネントを2,227行から1,526行に削減し、5つの独立モジュール(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState)に分離。保守性が大幅に向上しました。
|
||||||
|
|
||||||
|
### MCP/Skills 共有コンポーネント
|
||||||
|
|
||||||
|
AppCountBar、AppToggleGroup、ListItemRow などの共有コンポーネントを抽出し、MCP と Skills パネルの重複コードを削減(#897、@PeanutSplash に感謝)。
|
||||||
|
|
||||||
|
### 設定ページリファクタリング
|
||||||
|
|
||||||
|
設定ページを5タブレイアウト(一般 | プロキシ | 詳細 | 使用量 | 情報)にリファクタリング。SettingsPage のコードを約716行から約426行に削減しました。
|
||||||
|
|
||||||
|
### その他の改善
|
||||||
|
|
||||||
|
- ターミナル統一: グローバル設定でターミナル選択を統一、WezTerm サポートを追加
|
||||||
|
- Claude モデル参照を 4.5 から 4.6 に更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## バグ修正
|
||||||
|
|
||||||
|
### 重大な修正
|
||||||
|
|
||||||
|
- **Windows ホームディレクトリ回帰**: デフォルトのホームディレクトリ解決を復元し、Git/MSYS 環境でのデータベースパス変更によるデータ「消失」を防止
|
||||||
|
- **Linux 白画面**: AMD GPU の WebKitGTK ハードウェアアクセラレーションを無効化し、一部の Linux システムの起動白画面問題を解決(#986、@ThendCN に感謝)
|
||||||
|
- **OpenAI Beta パラメータ**: `/v1/chat/completions` に `?beta=true` を追加しないように修正、Nvidia など OpenAI Chat 形式を使用するプロバイダーのリクエスト失敗を修正(#1052、@jnorthrup に感謝)
|
||||||
|
- **ヘルスチェック認証**: プロバイダーの `auth_mode` 設定を尊重し、Bearer 認証のみをサポートするプロキシサービスのヘルスチェック失敗を回避(#824、@Jassy930 に感謝)
|
||||||
|
|
||||||
|
### プロバイダープリセット修正
|
||||||
|
|
||||||
|
- OpenClaw `/v1` プレフィックスの二重パス問題を修正
|
||||||
|
- Opus 価格修正($15/$75 → $5/$25)と 4.6 へのアップグレード
|
||||||
|
- AIGoCode URL を `https://api.aigocode.com` に統一
|
||||||
|
- Zhipu GLM の古いパートナーステータスを削除
|
||||||
|
- 新規 Claude プロバイダー作成時の API Key 入力フィールドの表示を復元
|
||||||
|
- 非アクティブプロバイダーのクイックトグルを非表示、コンテキスト対応の JSON エディターヒントを表示
|
||||||
|
|
||||||
|
### OMO 修正
|
||||||
|
|
||||||
|
- omo-slim カテゴリチェックの補完(add/form/mutation パス)
|
||||||
|
- OMO Slim プロバイダー変更後のクエリキャッシュ無効化を修正
|
||||||
|
- OMO agent/category 推奨モデルをアップストリームソースと同期
|
||||||
|
- 「推奨を入力」ボタン失敗時の toast フィードバックを追加
|
||||||
|
- OMO/OMO Slim の最後のプロバイダー削除制限を撤廃
|
||||||
|
- OpenCode でモデル未設定時の保存を拒否(#932、@yovinchen に感謝)
|
||||||
|
|
||||||
|
### OpenClaw 修正
|
||||||
|
|
||||||
|
- 25個の欠落 i18n キー、key={index} を安定 ID に置換、ディープリンク additive マージなどのコードレビュー問題を修正
|
||||||
|
- EnvPanel 堅牢性強化(NaN ガード、配列インデックスではなくエントリーキー名を使用)
|
||||||
|
- i18n 重複キーのマージ、プロバイダーフォーム翻訳を復元
|
||||||
|
|
||||||
|
### プラットフォーム修正
|
||||||
|
|
||||||
|
- Windows サイレント起動時のウィンドウフラッシュ(#901、@funnytime75 に感謝)
|
||||||
|
- タイトルバーのダークモード追従(#903、@funnytime75 に感謝)
|
||||||
|
- Windows の Skills パスセパレーターマッチング(#868、@stmoonar に感謝)
|
||||||
|
- WSL ヘルパー関数の条件付きコンパイル
|
||||||
|
|
||||||
|
### UI 修正
|
||||||
|
|
||||||
|
- ツールバーの高さクリッピングによる AppSwitcher の遮蔽を修正
|
||||||
|
- 新バージョンがある場合、緑のチェックマークではなく更新バッジを表示
|
||||||
|
- セッションマネージャーボタンを Claude/Codex アプリでのみ表示
|
||||||
|
- SQL インポート/エクスポートカードのダークモードスタイルを統一(#1067、@SaladDay に感謝)
|
||||||
|
|
||||||
|
### その他の修正
|
||||||
|
|
||||||
|
- セッションマネージャーのハードコードされた中国語文字列を i18n キーに置換
|
||||||
|
- Skill ドキュメント URL のブランチとパスを修正(#977、@yovinchen に感謝)
|
||||||
|
- OpenCode install.sh インストールパス検出の補完(#988、@zhu-jl18 に感謝)
|
||||||
|
- Skill ZIP シンボリックリンク解決の修正(#1040、@yovinchen に感謝)
|
||||||
|
- MCP フォームに OpenCode チェックボックスを追加(#1026、@yovinchen に感謝)
|
||||||
|
- useProvidersQuery の自動インポート副作用を削除
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## パフォーマンス最適化
|
||||||
|
|
||||||
|
- セッションパネルの並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで、セッションリスト読み込み速度を大幅向上
|
||||||
|
- Tauri ローカル IPC の不要な query cache を削除し、メモリ使用量を削減
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ドキュメント
|
||||||
|
|
||||||
|
- スポンサー更新: SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax
|
||||||
|
- ユーザーマニュアルを追加(#979、@yovinchen に感謝)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- **OpenClaw は新しくサポートされたアプリです**: 関連機能を使用するには、先に OpenClaw CLI をインストールする必要があります。
|
||||||
|
- **部分キーフィールドマージは重要なアーキテクチャ変更です**: プロバイダー切り替え時に設定ファイルを完全上書きするのではなく、プロバイダー関連のキー値のみをマージするようになりました。以前の完全上書き動作に依存していた場合はご注意ください。
|
||||||
|
- **自動インポートは手動に変更されました**: 起動時に外部設定を自動インポートしなくなりました。必要に応じて「現在の設定をインポート」を手動でクリックしてください。
|
||||||
|
- **OMO と OMO Slim は相互排他**: 同時に一つだけ有効にできます。切り替え時にもう一方は自動的に無効になります。
|
||||||
|
- **バックアップ機能はデフォルトで有効**: ランタイム中に1時間ごとに自動バックアップします。バックアップパネルでポリシーを調整できます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特別な感謝
|
||||||
|
|
||||||
|
以下のコントリビューターの皆様、このリリースへの貢献に感謝します!
|
||||||
|
|
||||||
|
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ダウンロードとインストール
|
||||||
|
|
||||||
|
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||||
|
|
||||||
|
### システム要件
|
||||||
|
|
||||||
|
| システム | 最小バージョン | アーキテクチャ |
|
||||||
|
| -------- | -------------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 以降 | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 下表参照 | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| ---------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.11.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||||
|
| `CC-Switch-v3.11.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| -------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.11.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||||
|
| `CC-Switch-v3.11.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||||
|
|
||||||
|
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew tap farion1231/ccswitch
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||||
|
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
|
||||||
|
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# CC Switch v3.11.0
|
||||||
|
|
||||||
|
> OpenClaw 支持、会话管理器、备份管理与 50+ 项改进
|
||||||
|
|
||||||
|
**[English →](release-note-v3.11.0-en.md) | [日本語版 →](release-note-v3.11.0-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.11.0 是一次大规模更新,新增第五个应用 **OpenClaw** 的完整管理支持,同时带来全新的**会话管理器**和**备份管理**功能。此外,**Oh My OpenCode (OMO) 集成**、供应商切换的**部分键值合并**架构升级、**设置页面重构**等多项改进使整体体验更加完善。
|
||||||
|
|
||||||
|
**发布日期**:2026-02-26
|
||||||
|
|
||||||
|
**更新规模**:147 commits | 274 files changed | +32,179 / -5,467 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重点内容
|
||||||
|
|
||||||
|
- **OpenClaw 支持**:第五个受管理应用,含 13 个供应商预设、Env/Tools/AgentsDefaults 配置编辑器、Workspace 文件管理
|
||||||
|
- **会话管理器**:浏览五个应用的历史会话,支持目录导航和会话内搜索
|
||||||
|
- **备份管理**:独立备份面板,可配置策略、定时备份、迁移前自动备份
|
||||||
|
- **Oh My OpenCode 集成**:完整 OMO 配置管理,支持 OMO Slim 轻量模式
|
||||||
|
- **部分键值合并**:供应商切换不再全量覆写,保留用户的非供应商设置
|
||||||
|
- **设置页面重构**:5 标签页布局,代码量减少约 40%
|
||||||
|
- **6 组新供应商预设**:AWS Bedrock、SSAI Code、CrazyRouter、AICoding 等
|
||||||
|
- **Thinking Budget Rectifier**:代理矫正器,更精细的 thinking budget 控制
|
||||||
|
- **主题切换动画**:圆形揭示过渡动画,视觉体验升级
|
||||||
|
- **WebDAV 自动同步**:支持自动同步与大文件防护
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
### OpenClaw 支持(新增第五应用)
|
||||||
|
|
||||||
|
CC Switch 新增对 OpenClaw 的完整管理支持,这是继 Claude Code、Codex、Gemini CLI、OpenCode 之后的第五个受管理应用。
|
||||||
|
|
||||||
|
- **供应商管理**:新增、编辑、切换、删除 OpenClaw 供应商,含 13 个内置预设
|
||||||
|
- **配置编辑器**:Env(环境变量)、Tools(工具)、AgentsDefaults(代理默认值)三个专属配置面板
|
||||||
|
- **Workspace 面板**:支持 HEARTBEAT/BOOTSTRAP/BOOT 文件管理及每日记忆
|
||||||
|
- **Additive 叠加模式**:支持配置叠加而非覆盖
|
||||||
|
- **默认模型按钮**:一键填充推荐模型,添加供应商时自动将建议模型注册到 allowlist
|
||||||
|
- **品牌与交互**:专属品牌图标、应用切换淡入淡出过渡动画
|
||||||
|
- **深链接支持**:通过 URL 导入 OpenClaw 供应商配置
|
||||||
|
- **完整国际化**:中/英/日三语全面支持
|
||||||
|
|
||||||
|
### 会话管理器 Sessions
|
||||||
|
|
||||||
|
全新的会话管理器,帮助你浏览和检索历史会话记录。
|
||||||
|
|
||||||
|
- 支持浏览 Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw 五个应用的历史会话(#867,感谢 @TinsFox)
|
||||||
|
- 目录导航和会话内搜索
|
||||||
|
- 进入会话页面时默认过滤为当前应用,快速定位
|
||||||
|
- 并行目录扫描 + 头尾 JSONL 读取,优化加载性能
|
||||||
|
|
||||||
|
### 备份管理 Backup
|
||||||
|
|
||||||
|
独立的备份管理面板,让数据安全更有保障。
|
||||||
|
|
||||||
|
- 可配置备份策略:最大备份数量、自动清理规则
|
||||||
|
- 运行时每小时定期自动备份
|
||||||
|
- 数据库迁移前自动备份,带回填警告提示
|
||||||
|
- 支持备份重命名和删除(含确认对话框)
|
||||||
|
- 备份文件名使用本地时间,更直观
|
||||||
|
|
||||||
|
### Oh My OpenCode (OMO) 集成
|
||||||
|
|
||||||
|
完整的 Oh My OpenCode 配置文件管理。
|
||||||
|
|
||||||
|
- Agent 模型选择、Category 配置、推荐模型填充(#972,感谢 @yovinchen)
|
||||||
|
- 改进 Agent 模型选择 UX,修复 lowercase key 问题(#1004,感谢 @yovinchen)
|
||||||
|
- OMO Slim 轻量模式支持
|
||||||
|
- OMO 与 OMO Slim 互斥切换(数据库层级强制保证一致性)
|
||||||
|
|
||||||
|
### 工作空间 Workspace
|
||||||
|
|
||||||
|
- 每日记忆文件全文搜索,按日期排序
|
||||||
|
- 目录路径可点击跳转,快速打开文件位置
|
||||||
|
|
||||||
|
### 工具栏 Toolbar
|
||||||
|
|
||||||
|
- AppSwitcher 根据窗口宽度自动折叠为紧凑模式
|
||||||
|
- 紧凑模式切换平滑过渡动画
|
||||||
|
|
||||||
|
### 设置 Settings
|
||||||
|
|
||||||
|
- 代理和用量功能新增首次使用确认对话框,避免误操作
|
||||||
|
- 新增 `enableLocalProxy` 开关,控制主页代理 UI 显示
|
||||||
|
- 更精细的本地环境检查:CLI 工具版本检测(#870,感谢 @kv-chiu)、Volta 路径检测(#969,感谢 @myjustify)
|
||||||
|
|
||||||
|
### 供应商预设 Preset
|
||||||
|
|
||||||
|
- **AWS Bedrock**:支持 AKSK 和 API Key 两种认证方式(#1047,感谢 @keithyt06)
|
||||||
|
- **SSAI Code**:合作伙伴预设,覆盖五端
|
||||||
|
- **CrazyRouter**:合作伙伴预设及专属图标
|
||||||
|
- **AICoding**:合作伙伴预设及推广文案
|
||||||
|
- 更新国内模型供应商预设至最新版本
|
||||||
|
- Qwen Coder 重命名为百炼 (Bailian)(#965,感谢 @zhu-jl18)
|
||||||
|
|
||||||
|
### 其他新功能
|
||||||
|
|
||||||
|
- **Thinking Budget Rectifier**:代理矫正器,更精细地控制 thinking budget 分配(#1005,感谢 @yovinchen)
|
||||||
|
- **WebDAV 自动同步**:支持自动同步配置,并增加大文件防护(#923,感谢 @clx20000410;#1043,感谢 @SaladDay)
|
||||||
|
- **主题切换动画**:圆形揭示过渡动画,视觉体验更流畅(#905,感谢 @funnytime75)
|
||||||
|
- **Claude 配置编辑器快速开关**:快速切换常用配置项(#1012,感谢 @JIA-ss)
|
||||||
|
- **动态端点提示**:根据 API 格式选择动态显示端点提示文本(#860,感谢 @zhu-jl18)
|
||||||
|
- **用量仪表盘增强**:自动刷新、更强健的数据格式化(#942,感谢 @yovinchen)
|
||||||
|
- **新增定价数据**:claude-opus-4-6 和 gpt-5.3-codex(#943,感谢 @yovinchen)
|
||||||
|
- **静默启动优化**:静默启动选项仅在开机启动开启时显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构改进
|
||||||
|
|
||||||
|
### 部分键值合并(重要变更)
|
||||||
|
|
||||||
|
供应商切换从全量配置覆写改为部分键值合并策略(#1098)。切换供应商时,仅更新供应商相关的键值,保留用户的非供应商设置(如插件配置、MCP 配置、权限设置等)。此次重构删除了 6 个前端文件、约 150 行后端死代码。
|
||||||
|
|
||||||
|
### 手动导入替代自动导入
|
||||||
|
|
||||||
|
启动时不再自动导入外部配置,改为手动点击"导入当前配置"按钮,避免意外覆盖用户数据。
|
||||||
|
|
||||||
|
### OMO Variant 参数化
|
||||||
|
|
||||||
|
通过 `OmoVariant` 结构体参数化消除 OMO 模块约 250 行重复代码。
|
||||||
|
|
||||||
|
### OMO 公共配置移除
|
||||||
|
|
||||||
|
删除二层合并系统,减少约 1,733 行代码,简化架构。
|
||||||
|
|
||||||
|
### ProviderForm 拆分
|
||||||
|
|
||||||
|
ProviderForm 组件从 2,227 行减至 1,526 行,提取 5 个独立模块(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState),可维护性显著提升。
|
||||||
|
|
||||||
|
### MCP/Skills 共享组件
|
||||||
|
|
||||||
|
提取 AppCountBar、AppToggleGroup、ListItemRow 等共享组件,减少 MCP 和 Skills 面板的重复代码(#897,感谢 @PeanutSplash)。
|
||||||
|
|
||||||
|
### 设置页面重构
|
||||||
|
|
||||||
|
设置页面重构为 5 标签页布局(通用 | 代理 | 高级 | 用量 | 关于),SettingsPage 代码从约 716 行减至约 426 行。
|
||||||
|
|
||||||
|
### 其他改进
|
||||||
|
|
||||||
|
- 终端统一:全局设置统一终端选择,新增 WezTerm 支持
|
||||||
|
- Claude 模型引用从 4.5 更新到 4.6
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 修复
|
||||||
|
|
||||||
|
### 严重修复
|
||||||
|
|
||||||
|
- **Windows 主目录回归**:恢复默认主目录解析,防止 Git/MSYS 环境下数据库路径变更导致数据"丢失"
|
||||||
|
- **Linux 白屏**:禁用 AMD GPU 的 WebKitGTK 硬件加速,解决部分 Linux 系统启动白屏问题(#986,感谢 @ThendCN)
|
||||||
|
- **OpenAI Beta 参数**:不再为 `/v1/chat/completions` 添加 `?beta=true`,修复 Nvidia 等使用 OpenAI Chat 格式的供应商请求失败(#1052,感谢 @jnorthrup)
|
||||||
|
- **健康检查认证**:尊重供应商 `auth_mode` 设置,避免仅支持 Bearer 认证的代理服务健康检查失败(#824,感谢 @Jassy930)
|
||||||
|
|
||||||
|
### 供应商预设修复
|
||||||
|
|
||||||
|
- 修复 OpenClaw `/v1` 前缀双重路径问题
|
||||||
|
- Opus 定价修正($15/$75 → $5/$25)并升级到 4.6
|
||||||
|
- AIGoCode URL 统一为 `https://api.aigocode.com`
|
||||||
|
- Zhipu GLM 移除过时合作伙伴状态
|
||||||
|
- 新建 Claude 供应商时 API Key 输入框可见性恢复
|
||||||
|
- 非活跃供应商隐藏快速开关,显示上下文感知的 JSON 编辑器提示
|
||||||
|
|
||||||
|
### OMO 修复
|
||||||
|
|
||||||
|
- omo-slim 分类检查补齐(add/form/mutation 路径)
|
||||||
|
- OMO Slim 供应商变更后正确失效查询缓存
|
||||||
|
- OMO agent/category 推荐模型与上游源同步
|
||||||
|
- "填充推荐"按钮失败时增加 toast 反馈
|
||||||
|
- 移除 OMO/OMO Slim 最后一个供应商的删除限制
|
||||||
|
- OpenCode 未配置模型时拒绝保存(#932,感谢 @yovinchen)
|
||||||
|
|
||||||
|
### OpenClaw 修复
|
||||||
|
|
||||||
|
- 修复 25 个缺失 i18n key、替换 key={index} 为稳定 ID、深链接 additive 合并等代码审查问题
|
||||||
|
- EnvPanel 健壮性增强(NaN 守卫、使用条目键名而非数组索引)
|
||||||
|
- i18n 重复键合并,恢复供应商表单翻译
|
||||||
|
|
||||||
|
### 平台修复
|
||||||
|
|
||||||
|
- Windows 静默启动时窗口闪烁(#901,感谢 @funnytime75)
|
||||||
|
- 标题栏暗黑模式跟随主题(#903,感谢 @funnytime75)
|
||||||
|
- Windows Skills 路径分隔符匹配(#868,感谢 @stmoonar)
|
||||||
|
- WSL 辅助函数条件编译
|
||||||
|
|
||||||
|
### UI 修复
|
||||||
|
|
||||||
|
- 工具栏高度裁切导致 AppSwitcher 被遮挡
|
||||||
|
- 有新版本时显示更新徽章而非绿色对勾
|
||||||
|
- 仅 Claude/Codex 应用显示会话管理器按钮
|
||||||
|
- SQL 导入/导出卡片暗黑模式样式统一(#1067,感谢 @SaladDay)
|
||||||
|
|
||||||
|
### 其他修复
|
||||||
|
|
||||||
|
- 会话管理器硬编码中文字符串替换为 i18n key
|
||||||
|
- Skill 文档 URL 分支和路径修正(#977,感谢 @yovinchen)
|
||||||
|
- OpenCode install.sh 安装路径检测补齐(#988,感谢 @zhu-jl18)
|
||||||
|
- Skill ZIP 符号链接解析修复(#1040,感谢 @yovinchen)
|
||||||
|
- MCP 表单补齐 OpenCode 复选框(#1026,感谢 @yovinchen)
|
||||||
|
- useProvidersQuery 中自动导入副作用移除
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
- 会话面板并行目录扫描 + 头尾 JSONL 读取,大幅提升会话列表加载速度
|
||||||
|
- 移除 Tauri 本地 IPC 不必要的 query cache,减少内存占用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 赞助商更新:SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax
|
||||||
|
- 新增用户手册(#979,感谢 @yovinchen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 说明与注意事项
|
||||||
|
|
||||||
|
- **OpenClaw 为新支持的应用**:需要先安装 OpenClaw CLI 才能使用相关功能。
|
||||||
|
- **部分键值合并是重要架构变更**:供应商切换不再全量覆写配置文件,而是仅合并供应商相关的键值。如果你之前依赖全量覆写行为,请注意此变更。
|
||||||
|
- **自动导入已改为手动**:启动时不再自动导入外部配置,请在需要时手动点击"导入当前配置"。
|
||||||
|
- **OMO 与 OMO Slim 互斥**:同一时间只能启用其中一个,切换时另一个会自动禁用。
|
||||||
|
- **备份功能默认开启**:运行时每小时自动备份,可在备份面板调整策略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特别感谢
|
||||||
|
|
||||||
|
感谢以下贡献者为本版本做出的贡献!
|
||||||
|
|
||||||
|
@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
| 系统 | 最低版本 | 架构 |
|
||||||
|
| ------- | ----------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 及以上 | x64 |
|
||||||
|
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 见下表 | x64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| ---------------------------------------- | ----------------------------------- |
|
||||||
|
| `CC-Switch-v3.11.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||||
|
| `CC-Switch-v3.11.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| -------------------------------- | --------------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.11.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||||
|
| `CC-Switch-v3.11.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||||
|
|
||||||
|
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||||
|
|
||||||
|
### 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
|
- 文档版本:v3.11.0
|
||||||
- 最后更新:2026-02-09
|
- 最后更新:2026-02-26
|
||||||
- 适用于 CC Switch v3.10.0+
|
- 适用于 CC Switch v3.11.0+
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.10.3",
|
"version": "3.11.0",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.3"
|
version = "3.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.3"
|
version = "3.11.0"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -352,49 +352,6 @@ impl FromStr for AppType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct CommonConfigSnippets {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub codex: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub gemini: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub opencode: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub openclaw: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
|
||||||
/// 获取指定应用的通用配置片段
|
|
||||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude.as_ref(),
|
|
||||||
AppType::Codex => self.codex.as_ref(),
|
|
||||||
AppType::Gemini => self.gemini.as_ref(),
|
|
||||||
AppType::OpenCode => self.opencode.as_ref(),
|
|
||||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置指定应用的通用配置片段
|
|
||||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude = snippet,
|
|
||||||
AppType::Codex => self.codex = snippet,
|
|
||||||
AppType::Gemini => self.gemini = snippet,
|
|
||||||
AppType::OpenCode => self.opencode = snippet,
|
|
||||||
AppType::OpenClaw => self.openclaw = snippet,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 多应用配置结构(向后兼容)
|
/// 多应用配置结构(向后兼容)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
@@ -412,12 +369,6 @@ pub struct MultiAppConfig {
|
|||||||
/// Claude Skills 配置
|
/// Claude Skills 配置
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: SkillStore,
|
pub skills: SkillStore,
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[serde(default)]
|
|
||||||
pub common_config_snippets: CommonConfigSnippets,
|
|
||||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude_common_config_snippet: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -439,8 +390,6 @@ impl Default for MultiAppConfig {
|
|||||||
mcp: McpRoot::default(),
|
mcp: McpRoot::default(),
|
||||||
prompts: PromptRoot::default(),
|
prompts: PromptRoot::default(),
|
||||||
skills: SkillStore::default(),
|
skills: SkillStore::default(),
|
||||||
common_config_snippets: CommonConfigSnippets::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,15 +483,6 @@ impl MultiAppConfig {
|
|||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
|
||||||
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
|
||||||
log::info!(
|
|
||||||
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
|
||||||
);
|
|
||||||
config.common_config_snippets.claude = Some(old_claude_snippet);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if updated {
|
if updated {
|
||||||
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||||
config.save()?;
|
config.save()?;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::codex_config;
|
use crate::codex_config;
|
||||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
use crate::settings;
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||||
@@ -16,18 +15,6 @@ pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
|||||||
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
fn invalid_json_format_error(error: serde_json::Error) -> String {
|
|
||||||
let lang = settings::get_settings()
|
|
||||||
.language
|
|
||||||
.unwrap_or_else(|| "zh".to_string());
|
|
||||||
|
|
||||||
match lang.as_str() {
|
|
||||||
"en" => format!("Invalid JSON format: {error}"),
|
|
||||||
"ja" => format!("JSON形式が無効です: {error}"),
|
|
||||||
_ => format!("无效的 JSON 格式: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||||
@@ -163,110 +150,3 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_common_config_snippet(
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_config_snippet("claude")
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_claude_common_config_snippet(
|
|
||||||
snippet: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if !snippet.trim().is_empty() {
|
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = if snippet.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(snippet)
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_config_snippet("claude", value)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_common_config_snippet(
|
|
||||||
app_type: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_config_snippet(&app_type)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_common_config_snippet(
|
|
||||||
app_type: String,
|
|
||||||
snippet: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if !snippet.trim().is_empty() {
|
|
||||||
match app_type.as_str() {
|
|
||||||
"claude" | "gemini" | "omo" => {
|
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
|
||||||
.map_err(invalid_json_format_error)?;
|
|
||||||
}
|
|
||||||
"codex" => {}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = if snippet.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(snippet)
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_config_snippet(&app_type, value)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if app_type == "omo"
|
|
||||||
&& state
|
|
||||||
.db
|
|
||||||
.get_current_omo_provider("opencode")
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
crate::services::OmoService::write_config_to_file(state.inner())
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn extract_common_config_snippet(
|
|
||||||
appType: String,
|
|
||||||
settingsConfig: Option<String>,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
|
||||||
let settings: serde_json::Value =
|
|
||||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
|
||||||
|
|
||||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
|
||||||
app,
|
|
||||||
&settings,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use tauri_plugin_dialog::DialogExt;
|
|||||||
use crate::commands::sync_support::{
|
use crate::commands::sync_support::{
|
||||||
post_sync_warning_from_result, run_post_import_sync, success_payload_with_warning,
|
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::error::AppError;
|
||||||
use crate::services::provider::ProviderService;
|
use crate::services::provider::ProviderService;
|
||||||
use crate::store::AppState;
|
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()))
|
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())
|
||||||
|
}
|
||||||
|
|||||||
+240
-48
@@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
|
|||||||
use crate::services::ProviderService;
|
use crate::services::ProviderService;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
@@ -85,50 +86,122 @@ pub struct ToolVersion {
|
|||||||
version: Option<String>,
|
version: Option<String>,
|
||||||
latest_version: Option<String>, // 新增字段:最新版本
|
latest_version: Option<String>, // 新增字段:最新版本
|
||||||
error: 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]
|
#[tauri::command]
|
||||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
pub async fn get_tool_versions(
|
||||||
let tools = vec!["claude", "codex", "gemini", "opencode"];
|
tools: Option<Vec<String>>,
|
||||||
|
wsl_shell_by_tool: Option<HashMap<String, WslShellPreferenceInput>>,
|
||||||
|
) -> Result<Vec<ToolVersion>, String> {
|
||||||
|
let requested: Vec<&str> = if let Some(tools) = tools.as_ref() {
|
||||||
|
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||||
|
VALID_TOOLS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|t| set.contains(t))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
VALID_TOOLS.to_vec()
|
||||||
|
};
|
||||||
let mut results = Vec::new();
|
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 客户端(已包含代理配置)
|
// 使用全局 HTTP 客户端(已包含代理配置)
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
for tool in tools {
|
// 1. 获取本地版本
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
let (local_version, local_error) = if let Some(distro) = wsl_distro.as_deref() {
|
||||||
let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) {
|
try_get_version_wsl(tool, distro, wsl_shell, wsl_shell_flag)
|
||||||
try_get_version_wsl(tool, &distro)
|
} else {
|
||||||
|
let direct_result = try_get_version(tool);
|
||||||
|
if direct_result.0.is_some() {
|
||||||
|
direct_result
|
||||||
} else {
|
} else {
|
||||||
// 先尝试直接执行
|
scan_cli_version(tool)
|
||||||
let direct_result = try_get_version(tool);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if direct_result.0.is_some() {
|
// 2. 获取远程最新版本
|
||||||
direct_result
|
let latest_version = match tool {
|
||||||
} else {
|
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
||||||
// 扫描常见的 npm 全局安装路径
|
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
||||||
scan_cli_version(tool)
|
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
||||||
}
|
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
||||||
};
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
// 2. 获取远程最新版本
|
ToolVersion {
|
||||||
let latest_version = match tool {
|
name: tool.to_string(),
|
||||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
version: local_version,
|
||||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
latest_version,
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
error: local_error,
|
||||||
"opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await,
|
env_type,
|
||||||
_ => None,
|
wsl_distro,
|
||||||
};
|
|
||||||
|
|
||||||
results.push(ToolVersion {
|
|
||||||
name: tool.to_string(),
|
|
||||||
version: local_version,
|
|
||||||
latest_version,
|
|
||||||
error: local_error,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to fetch latest version from npm registry
|
/// Helper function to fetch latest version from npm registry
|
||||||
@@ -242,8 +315,38 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
|
|||||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
.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")]
|
#[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;
|
use std::process::Command;
|
||||||
|
|
||||||
// 防御性断言:tool 只能是预定义的值
|
// 防御性断言:tool 只能是预定义的值
|
||||||
@@ -257,15 +360,47 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
|||||||
return (None, Some(format!("[WSL:{distro}] invalid distro name")));
|
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")
|
let output = Command::new("wsl.exe")
|
||||||
.args([
|
.args(["-d", distro, "--", &shell, flag, &cmd])
|
||||||
"-d",
|
|
||||||
distro,
|
|
||||||
"--",
|
|
||||||
"sh",
|
|
||||||
"-lc",
|
|
||||||
&format!("{tool} --version"),
|
|
||||||
])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
@@ -306,7 +441,12 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option<String>, Option<Stri
|
|||||||
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。
|
||||||
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[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,
|
None,
|
||||||
Some("WSL check not supported on this platform".to_string()),
|
Some("WSL check not supported on this platform".to_string()),
|
||||||
@@ -517,6 +657,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
(None, Some("not installed or not executable".to_string()))
|
(None, Some("not installed or not executable".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
|
||||||
let override_dir = match tool {
|
let override_dir = match tool {
|
||||||
"claude" => crate::settings::get_claude_override_dir(),
|
"claude" => crate::settings::get_claude_override_dir(),
|
||||||
@@ -554,12 +695,6 @@ fn wsl_distro_from_path(path: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 非 Windows 平台不支持 WSL 路径解析
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
fn wsl_distro_from_path(_path: &Path) -> Option<String> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 打开指定提供商的终端
|
/// 打开指定提供商的终端
|
||||||
///
|
///
|
||||||
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端
|
||||||
@@ -1061,6 +1196,63 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::path::PathBuf;
|
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]
|
#[test]
|
||||||
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
fn opencode_extra_search_paths_includes_install_and_fallback_dirs() {
|
||||||
let home = PathBuf::from("/home/tester");
|
let home = PathBuf::from("/home/tester");
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::services::omo::OmoLocalFileData;
|
use crate::services::omo::{OmoLocalFileData, SLIM, STANDARD};
|
||||||
use crate::services::OmoService;
|
use crate::services::OmoService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn read_omo_local_file() -> Result<OmoLocalFileData, String> {
|
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]
|
#[tauri::command]
|
||||||
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
|
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
|
||||||
let provider = state
|
let provider = state
|
||||||
.db
|
.db
|
||||||
.get_current_omo_provider("opencode")
|
.get_current_omo_provider("opencode", "omo")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(provider.map(|p| p.id).unwrap_or_default())
|
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") {
|
if p.category.as_deref() == Some("omo") {
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.clear_omo_provider_current("opencode", id)
|
.clear_omo_provider_current("opencode", id, "omo")
|
||||||
.map_err(|e| e.to_string())?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OMO Slim commands ───────────────────────────────────────
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
let providers = state
|
||||||
.db
|
.db
|
||||||
.get_all_providers("opencode")
|
.get_all_providers("opencode")
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let count = providers
|
for (id, p) in &providers {
|
||||||
.values()
|
if p.category.as_deref() == Some("omo-slim") {
|
||||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
state
|
||||||
.count();
|
.db
|
||||||
Ok(count)
|
.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::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService};
|
use crate::services::{
|
||||||
|
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||||
|
};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
@@ -67,7 +69,11 @@ pub fn remove_provider_from_live_config(
|
|||||||
.map_err(|e| e.to_string())
|
.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)
|
ProviderService::switch(state, app_type, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +82,7 @@ pub fn switch_provider_test_hook(
|
|||||||
state: &AppState,
|
state: &AppState,
|
||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<SwitchResult, AppError> {
|
||||||
switch_provider_internal(state, app_type, id)
|
switch_provider_internal(state, app_type, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,15 +91,15 @@ pub fn switch_provider(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
app: String,
|
app: String,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<bool, String> {
|
) -> Result<SwitchResult, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
switch_provider_internal(&state, app_type, &id)
|
switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string())
|
||||||
.map(|_| true)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||||
ProviderService::import_default_config(state, app_type)
|
let imported = ProviderService::import_default_config(state, app_type)?;
|
||||||
|
|
||||||
|
Ok(imported)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||||
@@ -161,6 +167,12 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
|||||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
|
||||||
|
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_api_endpoints(
|
pub async fn test_api_endpoints(
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
|
|||||||
@@ -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::config::write_text_file;
|
||||||
use crate::openclaw_config::get_openclaw_dir;
|
use crate::openclaw_config::get_openclaw_dir;
|
||||||
|
|
||||||
@@ -24,6 +29,281 @@ fn validate_filename(filename: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
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.
|
/// Read an OpenClaw workspace file content.
|
||||||
/// Returns None if the file does not exist.
|
/// Returns None if the file does not exist.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -58,3 +338,25 @@ pub async fn write_workspace_file(filename: String, content: String) -> Result<(
|
|||||||
write_text_file(&path, &content)
|
write_text_file(&path, &content)
|
||||||
.map_err(|e| format!("Failed to write workspace file {filename}: {e}"))
|
.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 导出/导入和二进制快照备份功能。
|
//! 提供 SQL 导出/导入和二进制快照备份功能。
|
||||||
|
|
||||||
use super::{lock_conn, Database, DB_BACKUP_RETAIN};
|
use super::{lock_conn, Database};
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use chrono::Utc;
|
use chrono::{Local, Utc};
|
||||||
use rusqlite::backup::Backup;
|
use rusqlite::backup::Backup;
|
||||||
use rusqlite::types::ValueRef;
|
use rusqlite::types::ValueRef;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@@ -15,6 +15,15 @@ use tempfile::NamedTempFile;
|
|||||||
|
|
||||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
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 {
|
impl Database {
|
||||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
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)
|
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 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");
|
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||||
if !db_path.exists() {
|
if !db_path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -134,7 +182,7 @@ impl Database {
|
|||||||
|
|
||||||
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
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_id = base_id.clone();
|
||||||
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||||
let mut counter = 1;
|
let mut counter = 1;
|
||||||
@@ -161,6 +209,7 @@ impl Database {
|
|||||||
|
|
||||||
/// 清理旧的数据库备份,保留最新的 N 个
|
/// 清理旧的数据库备份,保留最新的 N 个
|
||||||
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
|
||||||
|
let retain = crate::settings::effective_backup_retain_count();
|
||||||
let entries = match fs::read_dir(dir) {
|
let entries = match fs::read_dir(dir) {
|
||||||
Ok(iter) => iter
|
Ok(iter) => iter
|
||||||
.filter_map(|entry| entry.ok())
|
.filter_map(|entry| entry.ok())
|
||||||
@@ -175,11 +224,11 @@ impl Database {
|
|||||||
Err(_) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if entries.len() <= DB_BACKUP_RETAIN {
|
if entries.len() <= retain {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
|
let remove_count = entries.len().saturating_sub(retain);
|
||||||
let mut sorted = entries;
|
let mut sorted = entries;
|
||||||
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
|
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 failover;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod omo;
|
|
||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
@@ -16,4 +15,3 @@ pub mod universal_providers;
|
|||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
// 导出 FailoverQueueItem 供外部使用
|
// 导出 FailoverQueueItem 供外部使用
|
||||||
pub use failover::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,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
|
category: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let mut conn = lock_conn!(self.conn);
|
let mut conn = lock_conn!(self.conn);
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'",
|
"UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2",
|
||||||
params![app_type],
|
params![app_type, category],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.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
|
let updated = tx
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
"UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type, category],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
if updated != 1 {
|
if updated != 1 {
|
||||||
return Err(AppError::Database(format!(
|
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()))?;
|
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -393,12 +407,13 @@ impl Database {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
|
category: &str,
|
||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
match conn.query_row(
|
match conn.query_row(
|
||||||
"SELECT is_current FROM providers
|
"SELECT is_current FROM providers
|
||||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type, category],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
) {
|
) {
|
||||||
Ok(is_current) => Ok(is_current),
|
Ok(is_current) => Ok(is_current),
|
||||||
@@ -411,25 +426,30 @@ impl Database {
|
|||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
|
category: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE providers SET is_current = 0
|
"UPDATE providers SET is_current = 0
|
||||||
WHERE id = ?1 AND app_type = ?2 AND category = 'omo'",
|
WHERE id = ?1 AND app_type = ?2 AND category = ?3",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type, category],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
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 conn = lock_conn!(self.conn);
|
||||||
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
|
let row_data: Result<OmoProviderRow, rusqlite::Error> = conn.query_row(
|
||||||
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
|
"SELECT id, name, settings_config, category, created_at, sort_index, notes, meta
|
||||||
FROM providers
|
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",
|
LIMIT 1",
|
||||||
params![app_type],
|
params![app_type, category],
|
||||||
|row| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
row.get(0)?,
|
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 {
|
match row_data {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
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| {
|
let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| {
|
||||||
AppError::Database(format!(
|
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() {
|
let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() {
|
||||||
@@ -461,7 +481,7 @@ impl Database {
|
|||||||
} else {
|
} else {
|
||||||
serde_json::from_str(&meta_str).map_err(|e| {
|
serde_json::from_str(&meta_str).map_err(|e| {
|
||||||
AppError::Database(format!(
|
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,
|
name,
|
||||||
settings_config,
|
settings_config,
|
||||||
website_url: None,
|
website_url: None,
|
||||||
category,
|
category: Some(category.to_string()),
|
||||||
created_at,
|
created_at,
|
||||||
sort_index,
|
sort_index,
|
||||||
notes,
|
notes,
|
||||||
|
|||||||
@@ -38,31 +38,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config Snippets 辅助方法 ---
|
|
||||||
|
|
||||||
/// 获取通用配置片段
|
|
||||||
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
|
||||||
self.get_setting(&format!("common_config_{app_type}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置通用配置片段
|
|
||||||
pub fn set_config_snippet(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
snippet: Option<String>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let key = format!("common_config_{app_type}");
|
|
||||||
if let Some(value) = snippet {
|
|
||||||
self.set_setting(&key, &value)
|
|
||||||
} else {
|
|
||||||
// 如果为 None 则删除
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 全局出站代理 ---
|
// --- 全局出站代理 ---
|
||||||
|
|
||||||
/// 全局代理 URL 的存储键名
|
/// 全局代理 URL 的存储键名
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ impl Database {
|
|||||||
// 4. 迁移 Skills
|
// 4. 迁移 Skills
|
||||||
Self::migrate_skills(tx, config)?;
|
Self::migrate_skills(tx, config)?;
|
||||||
|
|
||||||
// 5. 迁移 Common Config
|
|
||||||
Self::migrate_common_config(tx, config)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,34 +209,4 @@ impl Database {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 迁移通用配置片段
|
|
||||||
fn migrate_common_config(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_claude", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_codex", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_gemini", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
//! └── settings.rs
|
//! └── settings.rs
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod backup;
|
pub(crate) mod backup;
|
||||||
mod dao;
|
mod dao;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod schema;
|
mod schema;
|
||||||
@@ -33,7 +33,6 @@ mod tests;
|
|||||||
|
|
||||||
// DAO 类型导出供外部使用
|
// DAO 类型导出供外部使用
|
||||||
pub use dao::FailoverQueueItem;
|
pub use dao::FailoverQueueItem;
|
||||||
pub use dao::OmoGlobalConfig;
|
|
||||||
|
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@@ -43,9 +42,6 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
// DAO 方法通过 impl Database 提供,无需额外导出
|
// DAO 方法通过 impl Database 提供,无需额外导出
|
||||||
|
|
||||||
/// 数据库备份保留数量
|
|
||||||
const DB_BACKUP_RETAIN: usize = 10;
|
|
||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||||
@@ -110,6 +106,22 @@ impl Database {
|
|||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
};
|
};
|
||||||
db.create_tables()?;
|
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.apply_schema_migrations()?;
|
||||||
db.ensure_model_pricing_seeded()?;
|
db.ensure_model_pricing_seeded()?;
|
||||||
|
|
||||||
|
|||||||
@@ -513,8 +513,6 @@ fn schema_dry_run_does_not_write_to_disk() {
|
|||||||
mcp: Default::default(),
|
mcp: Default::default(),
|
||||||
prompts: Default::default(),
|
prompts: Default::default(),
|
||||||
skills: Default::default(),
|
skills: Default::default(),
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dry-run should succeed without any file I/O errors
|
// Dry-run should succeed without any file I/O errors
|
||||||
@@ -563,8 +561,6 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
mcp: Default::default(),
|
mcp: Default::default(),
|
||||||
prompts: Default::default(),
|
prompts: Default::default(),
|
||||||
skills: Default::default(),
|
skills: Default::default(),
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dry-run should validate the full migration path
|
// Dry-run should validate the full migration path
|
||||||
|
|||||||
+67
-54
@@ -448,52 +448,7 @@ pub fn run() {
|
|||||||
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
// 2. OpenCode 供应商导入(累加式模式,需特殊处理)
|
||||||
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 与其他应用不同:配置文件中可同时存在多个供应商
|
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
|
||||||
// 需要遍历 provider 字段下的每个供应商并导入
|
// 需要遍历 provider 字段下的每个供应商并导入
|
||||||
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
|
||||||
@@ -512,7 +467,7 @@ pub fn run() {
|
|||||||
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
|
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !has_omo {
|
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) => {
|
Ok(provider) => {
|
||||||
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
|
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
|
||||||
}
|
}
|
||||||
@@ -526,7 +481,36 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
// 2.3 OMO Slim config import (when no omo-slim provider in DB, import from local)
|
||||||
|
{
|
||||||
|
let has_omo_slim = app_state
|
||||||
|
.db
|
||||||
|
.get_all_providers("opencode")
|
||||||
|
.map(|providers| {
|
||||||
|
providers
|
||||||
|
.values()
|
||||||
|
.any(|p| p.category.as_deref() == Some("omo-slim"))
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_omo_slim {
|
||||||
|
match crate::services::OmoService::import_from_local(&app_state, &crate::services::omo::SLIM) {
|
||||||
|
Ok(provider) => {
|
||||||
|
log::info!(
|
||||||
|
"✓ Imported OMO Slim config from local as provider '{}'",
|
||||||
|
provider.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(AppError::OmoConfigNotFound) => {
|
||||||
|
log::debug!("○ No OMO Slim config to import");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("✗ Failed to import OMO Slim config from local: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.4 OpenClaw 供应商导入(累加式模式,需特殊处理)
|
||||||
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
|
||||||
// 需要遍历 models.providers 字段下的每个供应商并导入
|
// 需要遍历 models.providers 字段下的每个供应商并导入
|
||||||
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
|
||||||
@@ -784,6 +768,25 @@ pub fn run() {
|
|||||||
|
|
||||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
restore_proxy_state_on_startup(&state).await;
|
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 初始化失败导致白屏
|
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
|
||||||
@@ -842,12 +845,8 @@ pub fn run() {
|
|||||||
commands::get_skills_migration_result,
|
commands::get_skills_migration_result,
|
||||||
commands::get_app_config_path,
|
commands::get_app_config_path,
|
||||||
commands::open_app_config_folder,
|
commands::open_app_config_folder,
|
||||||
commands::get_claude_common_config_snippet,
|
|
||||||
commands::set_claude_common_config_snippet,
|
|
||||||
commands::get_common_config_snippet,
|
|
||||||
commands::set_common_config_snippet,
|
|
||||||
commands::extract_common_config_snippet,
|
|
||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
|
commands::patch_claude_live_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::get_rectifier_config,
|
commands::get_rectifier_config,
|
||||||
@@ -912,6 +911,11 @@ pub fn run() {
|
|||||||
commands::save_file_dialog,
|
commands::save_file_dialog,
|
||||||
commands::open_file_dialog,
|
commands::open_file_dialog,
|
||||||
commands::open_zip_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,
|
commands::sync_current_providers_live,
|
||||||
// Deep link import
|
// Deep link import
|
||||||
commands::parse_deeplink,
|
commands::parse_deeplink,
|
||||||
@@ -1033,11 +1037,20 @@ pub fn run() {
|
|||||||
commands::set_window_theme,
|
commands::set_window_theme,
|
||||||
commands::read_omo_local_file,
|
commands::read_omo_local_file,
|
||||||
commands::get_current_omo_provider_id,
|
commands::get_current_omo_provider_id,
|
||||||
commands::get_omo_provider_count,
|
|
||||||
commands::disable_current_omo,
|
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)
|
// Workspace files (OpenClaw)
|
||||||
commands::read_workspace_file,
|
commands::read_workspace_file,
|
||||||
commands::write_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
|
let app = builder
|
||||||
|
|||||||
@@ -145,14 +145,25 @@ pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
|
|||||||
|
|
||||||
match plugins {
|
match plugins {
|
||||||
Some(arr) => {
|
Some(arr) => {
|
||||||
|
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
|
||||||
if plugin_name.starts_with("oh-my-opencode")
|
if plugin_name.starts_with("oh-my-opencode")
|
||||||
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
&& !plugin_name.starts_with("oh-my-opencode-slim")
|
||||||
{
|
{
|
||||||
|
// Adding standard OMO -> remove all Slim variants
|
||||||
arr.retain(|v| {
|
arr.retain(|v| {
|
||||||
v.as_str()
|
v.as_str()
|
||||||
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
.map(|s| !s.starts_with("oh-my-opencode-slim"))
|
||||||
.unwrap_or(true)
|
.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));
|
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ pub struct ProviderMeta {
|
|||||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||||
pub api_format: Option<String>,
|
pub api_format: Option<String>,
|
||||||
|
/// Claude 认证字段名(仅 Claude 供应商使用)
|
||||||
|
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
|
||||||
|
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||||
|
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key_field: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
|
|
||||||
use crate::app_config::{AppType, MultiAppConfig};
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -82,150 +78,4 @@ impl ConfigService {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步当前供应商到对应的 live 配置。
|
|
||||||
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_current_provider_for_app(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
app_type: &AppType,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let (current_id, provider) = {
|
|
||||||
let manager = match config.get_manager(app_type) {
|
|
||||||
Some(manager) => manager,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if manager.current.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_id = manager.current.clone();
|
|
||||||
let provider = match manager.providers.get(¤t_id) {
|
|
||||||
Some(provider) => provider.clone(),
|
|
||||||
None => {
|
|
||||||
log::warn!(
|
|
||||||
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(current_id, provider)
|
|
||||||
};
|
|
||||||
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::OpenCode => {
|
|
||||||
// OpenCode uses additive mode, no live sync needed
|
|
||||||
// OpenCode providers are managed directly in the config file
|
|
||||||
}
|
|
||||||
AppType::OpenClaw => {
|
|
||||||
// OpenClaw uses additive mode, no live sync needed
|
|
||||||
// OpenClaw providers are managed directly in the config file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_codex_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
|
||||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
|
||||||
})?;
|
|
||||||
let auth = settings.get("auth").ok_or_else(|| {
|
|
||||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
|
||||||
})?;
|
|
||||||
if !auth.is_object() {
|
|
||||||
return Err(AppError::Config(format!(
|
|
||||||
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
|
||||||
|
|
||||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
|
||||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
|
||||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
|
||||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
|
||||||
|
|
||||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
if let Some(obj) = target.settings_config.as_object_mut() {
|
|
||||||
obj.insert(
|
|
||||||
"config".to_string(),
|
|
||||||
serde_json::Value::String(cfg_text_after),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_claude_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
use crate::config::{read_json_file, write_json_file};
|
|
||||||
|
|
||||||
let settings_path = crate::config::get_claude_settings_path();
|
|
||||||
if let Some(parent) = settings_path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
|
||||||
write_json_file(&settings_path, &settings)?;
|
|
||||||
|
|
||||||
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
target.settings_config = live_after;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_gemini_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
use crate::gemini_config::{env_to_json, read_gemini_env};
|
|
||||||
|
|
||||||
ProviderService::write_gemini_live(provider)?;
|
|
||||||
|
|
||||||
// 读回实际写入的内容并更新到配置中(包含 settings.json)
|
|
||||||
let live_after_env = read_gemini_env()?;
|
|
||||||
let settings_path = crate::gemini_config::get_gemini_settings_path();
|
|
||||||
let live_after_config = if settings_path.exists() {
|
|
||||||
crate::config::read_json_file(&settings_path)?
|
|
||||||
} else {
|
|
||||||
serde_json::json!({})
|
|
||||||
};
|
|
||||||
let mut live_after = env_to_json(&live_after_env);
|
|
||||||
if let Some(obj) = live_after.as_object_mut() {
|
|
||||||
obj.insert("config".to_string(), live_after_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
target.settings_config = live_after;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub use config::ConfigService;
|
|||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
pub use omo::OmoService;
|
pub use omo::OmoService;
|
||||||
pub use prompt::PromptService;
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate, SwitchResult};
|
||||||
pub use proxy::ProxyService;
|
pub use proxy::ProxyService;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||||
|
|||||||
+157
-210
@@ -1,5 +1,4 @@
|
|||||||
use crate::config::write_json_file;
|
use crate::config::write_json_file;
|
||||||
use crate::database::OmoGlobalConfig;
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::opencode_config::get_opencode_dir;
|
use crate::opencode_config::get_opencode_dir;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
@@ -13,22 +12,60 @@ pub struct OmoLocalFileData {
|
|||||||
pub agents: Option<Value>,
|
pub agents: Option<Value>,
|
||||||
pub categories: Option<Value>,
|
pub categories: Option<Value>,
|
||||||
pub other_fields: Option<Value>,
|
pub other_fields: Option<Value>,
|
||||||
pub global: OmoGlobalConfig,
|
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub last_modified: Option<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;
|
pub struct OmoService;
|
||||||
|
|
||||||
impl OmoService {
|
impl OmoService {
|
||||||
fn config_path() -> PathBuf {
|
// ── Path helpers ────────────────────────────────────────
|
||||||
get_opencode_dir().join("oh-my-opencode.jsonc")
|
|
||||||
|
fn config_path(v: &OmoVariant) -> PathBuf {
|
||||||
|
get_opencode_dir().join(v.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
|
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
|
||||||
let config_path = Self::config_path();
|
let config_path = Self::config_path(v);
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
return Ok(config_path);
|
return Ok(config_path);
|
||||||
}
|
}
|
||||||
@@ -52,72 +89,22 @@ impl OmoService {
|
|||||||
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
|
// ── Field extraction ───────────────────────────────────
|
||||||
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",
|
|
||||||
];
|
|
||||||
|
|
||||||
|
fn extract_other_fields_with_keys(
|
||||||
|
obj: &Map<String, Value>,
|
||||||
|
known: &[&str],
|
||||||
|
) -> Map<String, Value> {
|
||||||
let mut other = Map::new();
|
let mut other = Map::new();
|
||||||
for (k, v) in obj {
|
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.insert(k.clone(), v.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
other
|
other
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_string_array(val: &Value) -> Vec<String> {
|
// ── Merge helpers ──────────────────────────────────────
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
|
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
|
||||||
if let Some(v) = 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>) {
|
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
|
||||||
if let Some(Value::Object(map)) = value {
|
if let Some(Value::Object(map)) = value {
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
@@ -142,115 +120,84 @@ impl OmoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_config_file() -> Result<(), AppError> {
|
// ── Public API (variant-parameterized) ─────────────────
|
||||||
let config_path = Self::config_path();
|
|
||||||
|
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
|
||||||
|
let config_path = Self::config_path(v);
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> {
|
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
|
||||||
let global = state.db.get_omo_global_config()?;
|
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
|
||||||
let current_omo = state.db.get_current_omo_provider("opencode")?;
|
|
||||||
|
|
||||||
let profile_data = current_omo.as_ref().map(|p| {
|
let profile_data = current_omo.as_ref().map(|p| {
|
||||||
let agents = p.settings_config.get("agents").cloned();
|
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 other_fields = p.settings_config.get("otherFields").cloned();
|
||||||
let use_common_config = p
|
(agents, categories, other_fields)
|
||||||
.settings_config
|
|
||||||
.get("useCommonConfig")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(true);
|
|
||||||
(agents, categories, other_fields, use_common_config)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let merged = Self::merge_config(&global, profile_data.as_ref());
|
let merged = Self::build_config(v, profile_data.as_ref());
|
||||||
let config_path = Self::config_path();
|
let config_path = Self::config_path(v);
|
||||||
|
|
||||||
if let Some(parent) = config_path.parent() {
|
if let Some(parent) = config_path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
write_json_file(&config_path, &merged)?;
|
write_json_file(&config_path, &merged)?;
|
||||||
|
crate::opencode_config::add_plugin(v.plugin_name)?;
|
||||||
crate::opencode_config::add_plugin("oh-my-opencode@latest")?;
|
log::info!("{} config written to {config_path:?}", v.label);
|
||||||
|
|
||||||
log::info!("OMO config written to {config_path:?}");
|
|
||||||
Ok(())
|
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 mut result = Map::new();
|
||||||
let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true);
|
if let Some((agents, categories, other_fields)) = profile_data {
|
||||||
|
|
||||||
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);
|
|
||||||
Self::insert_object_entries(&mut result, other_fields.as_ref());
|
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)
|
Value::Object(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn import_from_local(state: &AppState) -> Result<crate::provider::Provider, AppError> {
|
pub fn import_from_local(
|
||||||
let actual_path = Self::resolve_local_config_path()?;
|
|
||||||
Self::import_from_path(state, &actual_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn import_from_path(
|
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
path: &std::path::Path,
|
v: &OmoVariant,
|
||||||
) -> Result<crate::provider::Provider, AppError> {
|
) -> 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();
|
let mut settings = Map::new();
|
||||||
if let Some(agents) = obj.get("agents") {
|
if let Some(agents) = obj.get("agents") {
|
||||||
settings.insert("agents".to_string(), agents.clone());
|
settings.insert("agents".to_string(), agents.clone());
|
||||||
}
|
}
|
||||||
if let Some(categories) = obj.get("categories") {
|
if v.has_categories {
|
||||||
settings.insert("categories".to_string(), categories.clone());
|
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() {
|
if !other.is_empty() {
|
||||||
settings.insert("otherFields".to_string(), Value::Object(other));
|
settings.insert("otherFields".to_string(), Value::Object(other));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut global = state.db.get_omo_global_config()?;
|
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
|
||||||
Self::merge_global_from_obj(&obj, &mut global);
|
let name = format!(
|
||||||
global.updated_at = chrono::Utc::now().to_rfc3339();
|
"{} {}",
|
||||||
state.db.save_omo_global_config(&global)?;
|
v.import_label,
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||||||
let provider_id = format!("omo-{}", uuid::Uuid::new_v4());
|
);
|
||||||
let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M"));
|
|
||||||
let settings_config =
|
let settings_config =
|
||||||
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
|
||||||
@@ -259,7 +206,7 @@ impl OmoService {
|
|||||||
name,
|
name,
|
||||||
settings_config,
|
settings_config,
|
||||||
website_url: None,
|
website_url: None,
|
||||||
category: Some("omo".to_string()),
|
category: Some(v.category.to_string()),
|
||||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
||||||
sort_index: None,
|
sort_index: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
@@ -272,13 +219,13 @@ impl OmoService {
|
|||||||
state.db.save_provider("opencode", &provider)?;
|
state.db.save_provider("opencode", &provider)?;
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_omo_provider_current("opencode", &provider.id)?;
|
.set_omo_provider_current("opencode", &provider.id, v.category)?;
|
||||||
Self::write_config_to_file(state)?;
|
Self::write_config_to_file(state, v)?;
|
||||||
Ok(provider)
|
Ok(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_local_file() -> Result<OmoLocalFileData, AppError> {
|
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
|
||||||
let actual_path = Self::resolve_local_config_path()?;
|
let actual_path = Self::resolve_local_config_path(v)?;
|
||||||
let metadata = std::fs::metadata(&actual_path).ok();
|
let metadata = std::fs::metadata(&actual_path).ok();
|
||||||
let last_modified = metadata
|
let last_modified = metadata
|
||||||
.and_then(|m| m.modified().ok())
|
.and_then(|m| m.modified().ok())
|
||||||
@@ -286,37 +233,38 @@ impl OmoService {
|
|||||||
|
|
||||||
let obj = Self::read_jsonc_object(&actual_path)?;
|
let obj = Self::read_jsonc_object(&actual_path)?;
|
||||||
|
|
||||||
Ok(Self::build_local_file_data_from_obj(
|
Ok(Self::build_local_file_data(
|
||||||
|
v,
|
||||||
&obj,
|
&obj,
|
||||||
actual_path.to_string_lossy().to_string(),
|
actual_path.to_string_lossy().to_string(),
|
||||||
last_modified,
|
last_modified,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_local_file_data_from_obj(
|
fn build_local_file_data(
|
||||||
|
v: &OmoVariant,
|
||||||
obj: &Map<String, Value>,
|
obj: &Map<String, Value>,
|
||||||
file_path: String,
|
file_path: String,
|
||||||
last_modified: Option<String>,
|
last_modified: Option<String>,
|
||||||
) -> OmoLocalFileData {
|
) -> OmoLocalFileData {
|
||||||
let agents = obj.get("agents").cloned();
|
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() {
|
let other_fields = if other.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(Value::Object(other))
|
Some(Value::Object(other))
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut global = OmoGlobalConfig::default();
|
|
||||||
Self::merge_global_from_obj(obj, &mut global);
|
|
||||||
global.other_fields = other_fields.clone();
|
|
||||||
|
|
||||||
OmoLocalFileData {
|
OmoLocalFileData {
|
||||||
agents,
|
agents,
|
||||||
categories,
|
categories,
|
||||||
other_fields,
|
other_fields,
|
||||||
global,
|
|
||||||
file_path,
|
file_path,
|
||||||
last_modified,
|
last_modified,
|
||||||
}
|
}
|
||||||
@@ -399,26 +347,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_merge_config_empty() {
|
fn test_build_config_empty() {
|
||||||
let global = OmoGlobalConfig::default();
|
let merged = OmoService::build_config(&STANDARD, None);
|
||||||
let merged = OmoService::merge_config(&global, None);
|
|
||||||
assert!(merged.is_object());
|
assert!(merged.is_object());
|
||||||
|
assert!(merged.as_object().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_merge_config_with_profile() {
|
fn test_build_config_with_profile() {
|
||||||
let global = OmoGlobalConfig {
|
|
||||||
schema_url: Some("https://example.com/schema.json".to_string()),
|
|
||||||
disabled_agents: vec!["explore".to_string()],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let agents = Some(serde_json::json!({
|
let agents = Some(serde_json::json!({
|
||||||
"sisyphus": { "model": "claude-opus-4-5" }
|
"sisyphus": { "model": "claude-opus-4-5" }
|
||||||
}));
|
}));
|
||||||
let categories = None;
|
let categories = None;
|
||||||
let other_fields = None;
|
let other_fields = Some(serde_json::json!({
|
||||||
let profile_data = (agents, categories, other_fields, true);
|
"$schema": "https://example.com/schema.json",
|
||||||
let merged = OmoService::merge_config(&global, Some(&profile_data));
|
"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();
|
let obj = merged.as_object().unwrap();
|
||||||
|
|
||||||
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
assert_eq!(obj["$schema"], "https://example.com/schema.json");
|
||||||
@@ -428,28 +374,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_merge_config_without_common_config() {
|
fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() {
|
||||||
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() {
|
|
||||||
let obj = serde_json::json!({
|
let obj = serde_json::json!({
|
||||||
"$schema": "https://example.com/schema.json",
|
"$schema": "https://example.com/schema.json",
|
||||||
"disabled_agents": ["oracle"],
|
"disabled_agents": ["oracle"],
|
||||||
@@ -465,43 +390,65 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let obj_map = obj.as_object().unwrap().clone();
|
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,
|
&obj_map,
|
||||||
"/tmp/oh-my-opencode.jsonc".to_string(),
|
"/tmp/oh-my-opencode.jsonc".to_string(),
|
||||||
None,
|
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!(
|
assert_eq!(
|
||||||
data.global.schema_url.as_deref(),
|
other_obj.get("$schema").unwrap(),
|
||||||
Some("https://example.com/schema.json")
|
"https://example.com/schema.json"
|
||||||
);
|
);
|
||||||
assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data.other_fields,
|
other_obj.get("disabled_agents").unwrap(),
|
||||||
Some(serde_json::json!({
|
&serde_json::json!(["oracle"])
|
||||||
"custom_top_level": { "enabled": true }
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
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]
|
#[test]
|
||||||
fn test_merge_config_ignores_non_object_other_fields() {
|
fn test_build_config_ignores_non_object_other_fields() {
|
||||||
let global = OmoGlobalConfig {
|
|
||||||
other_fields: Some(serde_json::json!(["global_non_object"])),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let agents = None;
|
let agents = None;
|
||||||
let categories = None;
|
let categories = None;
|
||||||
let other_fields = Some(serde_json::json!("profile_non_object"));
|
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();
|
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"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,439 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Key fields definitions for partial merge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Claude env-level key fields that belong to the provider.
|
||||||
|
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||||
|
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
|
||||||
|
// --- API auth & endpoint ---
|
||||||
|
"ANTHROPIC_BASE_URL",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
// --- Model selection ---
|
||||||
|
"ANTHROPIC_MODEL",
|
||||||
|
"ANTHROPIC_REASONING_MODEL",
|
||||||
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||||
|
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||||
|
// --- AWS Bedrock ---
|
||||||
|
"CLAUDE_CODE_USE_BEDROCK",
|
||||||
|
"AWS_ACCESS_KEY_ID",
|
||||||
|
"AWS_SECRET_ACCESS_KEY",
|
||||||
|
"AWS_SESSION_TOKEN",
|
||||||
|
"AWS_REGION",
|
||||||
|
"AWS_PROFILE",
|
||||||
|
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
|
||||||
|
// --- Google Vertex AI ---
|
||||||
|
"CLAUDE_CODE_USE_VERTEX",
|
||||||
|
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||||
|
"CLOUD_ML_REGION",
|
||||||
|
// --- Microsoft Foundry ---
|
||||||
|
"CLAUDE_CODE_USE_FOUNDRY",
|
||||||
|
// --- Provider behavior ---
|
||||||
|
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||||
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
||||||
|
"API_TIMEOUT_MS",
|
||||||
|
"DISABLE_PROMPT_CACHING",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Claude top-level key fields (legacy + modern format).
|
||||||
|
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||||
|
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
|
||||||
|
"apiBaseUrl", // legacy
|
||||||
|
"primaryModel", // legacy
|
||||||
|
"smallFastModel", // legacy
|
||||||
|
"model", // modern
|
||||||
|
"apiKey", // Bedrock API Key auth
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Codex TOML key fields.
|
||||||
|
/// When adding a new field here, also update backfill_codex_key_fields().
|
||||||
|
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
|
||||||
|
"model_provider",
|
||||||
|
"model",
|
||||||
|
"model_reasoning_effort",
|
||||||
|
"review_model",
|
||||||
|
"plan_mode_reasoning_effort",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Gemini env-level key fields.
|
||||||
|
/// When adding a new field here, also update backfill_gemini_key_fields().
|
||||||
|
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
|
||||||
|
"GOOGLE_GEMINI_BASE_URL",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"GEMINI_MODEL",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Partial merge: write only key fields to live config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Write only provider-specific key fields to live configuration,
|
||||||
|
/// preserving all other user settings in the live file.
|
||||||
|
///
|
||||||
|
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
|
||||||
|
/// - `switch_normal()` — switching providers
|
||||||
|
/// - `sync_current_to_live()` — startup sync
|
||||||
|
/// - `add()` / `update()` when the provider is current
|
||||||
|
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => write_claude_live_partial(provider),
|
||||||
|
AppType::Codex => write_codex_live_partial(provider),
|
||||||
|
AppType::Gemini => write_gemini_live_partial(provider),
|
||||||
|
// Additive mode apps still use full snapshot
|
||||||
|
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
|
||||||
|
/// Used for user-level preferences (attribution, thinking, etc.) that are
|
||||||
|
/// independent of the active provider.
|
||||||
|
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||||
|
let path = get_claude_settings_path();
|
||||||
|
let mut live = if path.exists() {
|
||||||
|
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
json_merge_patch(&mut live, &patch);
|
||||||
|
let settings = sanitize_claude_settings_for_live(&live);
|
||||||
|
write_json_file(&path, &settings)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
|
||||||
|
fn json_merge_patch(target: &mut Value, patch: &Value) {
|
||||||
|
if let Some(patch_obj) = patch.as_object() {
|
||||||
|
if !target.is_object() {
|
||||||
|
*target = json!({});
|
||||||
|
}
|
||||||
|
let target_obj = target.as_object_mut().unwrap();
|
||||||
|
for (key, value) in patch_obj {
|
||||||
|
if value.is_null() {
|
||||||
|
target_obj.remove(key);
|
||||||
|
} else if value.is_object() {
|
||||||
|
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
|
||||||
|
json_merge_patch(entry, value);
|
||||||
|
// Clean up empty container objects
|
||||||
|
if entry.as_object().is_some_and(|o| o.is_empty()) {
|
||||||
|
target_obj.remove(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target_obj.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude: merge only key env and top-level fields into live settings.json
|
||||||
|
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let path = get_claude_settings_path();
|
||||||
|
|
||||||
|
// 1. Read existing live config (start from empty if file doesn't exist)
|
||||||
|
let mut live = if path.exists() {
|
||||||
|
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Ensure live.env exists as an object
|
||||||
|
if !live.get("env").is_some_and(|v| v.is_object()) {
|
||||||
|
live.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("env".into(), json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear key env fields from live, then write from provider
|
||||||
|
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
live_env.remove(*key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(provider_env) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = provider_env.get(*key) {
|
||||||
|
live_env.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle top-level legacy key fields
|
||||||
|
let live_obj = live.as_object_mut().unwrap();
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
live_obj.remove(*key);
|
||||||
|
}
|
||||||
|
if let Some(provider_obj) = provider.settings_config.as_object() {
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
if let Some(value) = provider_obj.get(*key) {
|
||||||
|
live_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sanitize and write
|
||||||
|
let settings = sanitize_claude_settings_for_live(&live);
|
||||||
|
write_json_file(&path, &settings)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codex: replace auth.json entirely, partially merge config.toml key fields
|
||||||
|
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let obj = provider
|
||||||
|
.settings_config
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||||
|
|
||||||
|
// auth.json is entirely provider-specific, replace it wholesale
|
||||||
|
let auth = obj
|
||||||
|
.get("auth")
|
||||||
|
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||||
|
|
||||||
|
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Read existing config.toml (or start from empty)
|
||||||
|
let config_path = get_codex_config_path();
|
||||||
|
let existing_toml = if config_path.exists() {
|
||||||
|
std::fs::read_to_string(&config_path).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse both existing and provider TOML
|
||||||
|
let mut live_doc = existing_toml
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
|
||||||
|
|
||||||
|
// Remove key fields from live doc
|
||||||
|
let live_root = live_doc.as_table_mut();
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
live_root.remove(key);
|
||||||
|
}
|
||||||
|
live_root.remove("model_providers");
|
||||||
|
|
||||||
|
// Parse provider TOML and extract key fields
|
||||||
|
if !provider_config_str.is_empty() {
|
||||||
|
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
|
||||||
|
let provider_root = provider_doc.as_table();
|
||||||
|
|
||||||
|
// Copy key top-level fields from provider
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
if let Some(item) = provider_root.get(key) {
|
||||||
|
live_root.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy model_providers table from provider
|
||||||
|
if let Some(mp) = provider_root.get("model_providers") {
|
||||||
|
live_root.insert("model_providers", mp.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write using atomic write
|
||||||
|
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
|
||||||
|
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
|
||||||
|
|
||||||
|
let auth_type = detect_gemini_auth_type(provider);
|
||||||
|
|
||||||
|
// 1. Read existing env from live .env file
|
||||||
|
let mut env_map = if get_gemini_env_path().exists() {
|
||||||
|
read_gemini_env().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Remove key fields from existing env
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
env_map.remove(*key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Extract key fields from provider and merge
|
||||||
|
if let Some(provider_env) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
|
||||||
|
if !value.is_empty() {
|
||||||
|
env_map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle auth type specific behavior
|
||||||
|
match auth_type {
|
||||||
|
GeminiAuthType::GoogleOfficial => {
|
||||||
|
// Google official uses OAuth, clear all env
|
||||||
|
env_map.clear();
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
}
|
||||||
|
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||||
|
// Validate and write env
|
||||||
|
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
|
||||||
|
use crate::gemini_config::get_gemini_settings_path;
|
||||||
|
let settings_path = get_gemini_settings_path();
|
||||||
|
|
||||||
|
if let Some(config_value) = provider.settings_config.get("config") {
|
||||||
|
if config_value.is_object() {
|
||||||
|
let mut merged = if settings_path.exists() {
|
||||||
|
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
if let (Some(merged_obj), Some(config_obj)) =
|
||||||
|
(merged.as_object_mut(), config_value.as_object())
|
||||||
|
{
|
||||||
|
for (k, v) in config_obj {
|
||||||
|
merged_obj.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_json_file(&settings_path, &merged)?;
|
||||||
|
} else if !config_value.is_null() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_config",
|
||||||
|
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||||
|
"Gemini config invalid: config must be an object or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Set security flag based on auth type
|
||||||
|
match auth_type {
|
||||||
|
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||||
|
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||||
|
crate::gemini_config::write_packycode_settings()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Backfill: extract only key fields from live config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Extract only provider-specific key fields from a live config value.
|
||||||
|
///
|
||||||
|
/// Used during backfill to ensure the provider's `settings_config` converges
|
||||||
|
/// to containing only key fields over time.
|
||||||
|
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => backfill_claude_key_fields(live_config),
|
||||||
|
AppType::Codex => backfill_codex_key_fields(live_config),
|
||||||
|
AppType::Gemini => backfill_gemini_key_fields(live_config),
|
||||||
|
// Additive mode: return full config (no backfill needed)
|
||||||
|
_ => live_config.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_claude_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// Extract key env fields
|
||||||
|
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||||
|
let mut env_obj = serde_json::Map::new();
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = live_env.get(*key) {
|
||||||
|
env_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !env_obj.is_empty() {
|
||||||
|
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key top-level fields
|
||||||
|
if let Some(live_obj) = live.as_object() {
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
if let Some(value) = live_obj.get(*key) {
|
||||||
|
result_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_codex_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// auth is entirely provider-specific — keep it as-is
|
||||||
|
if let Some(auth) = live.get("auth") {
|
||||||
|
result_obj.insert("auth".to_string(), auth.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key TOML fields from config string
|
||||||
|
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
|
||||||
|
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
|
||||||
|
let mut new_doc = toml_edit::DocumentMut::new();
|
||||||
|
let new_root = new_doc.as_table_mut();
|
||||||
|
|
||||||
|
// Copy key top-level fields
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
if let Some(item) = doc.as_table().get(key) {
|
||||||
|
new_root.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy model_providers table
|
||||||
|
if let Some(mp) = doc.as_table().get("model_providers") {
|
||||||
|
new_root.insert("model_providers", mp.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let toml_str = new_doc.to_string();
|
||||||
|
if !toml_str.trim().is_empty() {
|
||||||
|
result_obj.insert("config".to_string(), Value::String(toml_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_gemini_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// Extract key env fields
|
||||||
|
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||||
|
let mut env_obj = serde_json::Map::new();
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = live_env.get(*key) {
|
||||||
|
env_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !env_obj.is_empty() {
|
||||||
|
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync all providers to live configuration (for additive mode apps)
|
/// Sync all providers to live configuration (for additive mode apps)
|
||||||
///
|
///
|
||||||
/// Writes all providers from the database to the live configuration file.
|
/// Writes all providers from the database to the live configuration file.
|
||||||
@@ -286,7 +719,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
|||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
if let Some(provider) = providers.get(¤t_id) {
|
if let Some(provider) = providers.get(¤t_id) {
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_partial(&app_type, provider)?;
|
||||||
}
|
}
|
||||||
// Note: get_effective_current_provider already validates existence,
|
// Note: get_effective_current_provider already validates existence,
|
||||||
// so providers.get() should always succeed here
|
// so providers.get() should always succeed here
|
||||||
|
|||||||
@@ -27,17 +27,25 @@ pub use live::{
|
|||||||
|
|
||||||
// Internal re-exports (pub(crate))
|
// Internal re-exports (pub(crate))
|
||||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||||
pub(crate) use live::write_live_snapshot;
|
pub(crate) use live::write_live_partial;
|
||||||
|
|
||||||
// Internal re-exports
|
// Internal re-exports
|
||||||
use live::{
|
use live::{
|
||||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
|
||||||
|
write_live_snapshot,
|
||||||
};
|
};
|
||||||
use usage::validate_usage_script;
|
use usage::validate_usage_script;
|
||||||
|
|
||||||
/// Provider business logic service
|
/// Provider business logic service
|
||||||
pub struct ProviderService;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -77,47 +85,6 @@ mod tests {
|
|||||||
assert_eq!(api_key, "token");
|
assert_eq!(api_key, "token");
|
||||||
assert_eq!(base_url, "https://claude.example");
|
assert_eq!(base_url, "https://claude.example");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
|
||||||
let config_toml = r#"model_provider = "azure"
|
|
||||||
model = "gpt-4"
|
|
||||||
disable_response_storage = true
|
|
||||||
|
|
||||||
[model_providers.azure]
|
|
||||||
name = "Azure OpenAI"
|
|
||||||
base_url = "https://azure.example/v1"
|
|
||||||
wire_api = "responses"
|
|
||||||
|
|
||||||
[mcp_servers.my_server]
|
|
||||||
base_url = "http://localhost:8080"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let settings = json!({ "config": config_toml });
|
|
||||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
|
||||||
.expect("extract_codex_common_config should succeed");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!extracted
|
|
||||||
.lines()
|
|
||||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
|
||||||
"should remove top-level model_provider"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!extracted
|
|
||||||
.lines()
|
|
||||||
.any(|line| line.trim_start().starts_with("model =")),
|
|
||||||
"should remove top-level model"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!extracted.contains("[model_providers"),
|
|
||||||
"should remove entire model_providers table"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
extracted.contains("http://localhost:8080"),
|
|
||||||
"should keep mcp_servers.* base_url"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderService {
|
impl ProviderService {
|
||||||
@@ -166,11 +133,11 @@ impl ProviderService {
|
|||||||
|
|
||||||
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
|
||||||
if app_type.is_additive_mode() {
|
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)
|
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.
|
// Users must explicitly switch/apply an OMO provider to activate it.
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -185,7 +152,7 @@ impl ProviderService {
|
|||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_partial(&app_type, &provider)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -207,14 +174,33 @@ impl ProviderService {
|
|||||||
|
|
||||||
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
|
||||||
if app_type.is_additive_mode() {
|
if app_type.is_additive_mode() {
|
||||||
if matches!(app_type, AppType::OpenCode)
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
|
||||||
&& provider.category.as_deref() == Some("omo")
|
|
||||||
{
|
{
|
||||||
let is_omo_current = state
|
let is_omo_current =
|
||||||
.db
|
state
|
||||||
.is_omo_provider_current(app_type.as_str(), &provider.id)?;
|
.db
|
||||||
|
.is_omo_provider_current(app_type.as_str(), &provider.id, "omo")?;
|
||||||
if is_omo_current {
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -247,7 +233,7 @@ impl ProviderService {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
} else {
|
} else {
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_partial(&app_type, &provider)?;
|
||||||
// Sync MCP
|
// Sync MCP
|
||||||
McpService::sync_all_enabled(state)?;
|
McpService::sync_all_enabled(state)?;
|
||||||
}
|
}
|
||||||
@@ -264,31 +250,37 @@ impl ProviderService {
|
|||||||
// Additive mode apps - no current provider concept
|
// Additive mode apps - no current provider concept
|
||||||
if app_type.is_additive_mode() {
|
if app_type.is_additive_mode() {
|
||||||
if matches!(app_type, AppType::OpenCode) {
|
if matches!(app_type, AppType::OpenCode) {
|
||||||
let is_omo = state
|
let provider_category = state
|
||||||
.db
|
.db
|
||||||
.get_provider_by_id(id, app_type.as_str())?
|
.get_provider_by_id(id, app_type.as_str())?
|
||||||
.and_then(|p| p.category)
|
.and_then(|p| p.category);
|
||||||
.as_deref()
|
|
||||||
== Some("omo");
|
|
||||||
|
|
||||||
if is_omo {
|
if provider_category.as_deref() == Some("omo") {
|
||||||
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
|
let was_current =
|
||||||
let omo_count = state
|
state
|
||||||
.db
|
.db
|
||||||
.get_all_providers(app_type.as_str())?
|
.is_omo_provider_current(app_type.as_str(), id, "omo")?;
|
||||||
.values()
|
|
||||||
.filter(|p| p.category.as_deref() == Some("omo"))
|
|
||||||
.count();
|
|
||||||
|
|
||||||
if omo_count <= 1 && was_current {
|
|
||||||
return Err(AppError::Message(
|
|
||||||
"无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
state.db.delete_provider(app_type.as_str(), id)?;
|
state.db.delete_provider(app_type.as_str(), id)?;
|
||||||
if was_current {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -329,21 +321,46 @@ impl ProviderService {
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::OpenCode => {
|
AppType::OpenCode => {
|
||||||
let is_omo = state
|
let provider_category = state
|
||||||
.db
|
.db
|
||||||
.get_provider_by_id(id, app_type.as_str())?
|
.get_provider_by_id(id, app_type.as_str())?
|
||||||
.and_then(|p| p.category)
|
.and_then(|p| p.category);
|
||||||
.as_deref()
|
|
||||||
== Some("omo");
|
|
||||||
|
|
||||||
if is_omo {
|
if provider_category.as_deref() == Some("omo") {
|
||||||
state.db.clear_omo_provider_current(app_type.as_str(), id)?;
|
state
|
||||||
let still_has_current =
|
.db
|
||||||
state.db.get_current_omo_provider("opencode")?.is_some();
|
.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 {
|
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 {
|
} 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 {
|
} else {
|
||||||
remove_opencode_provider_from_live(id)?;
|
remove_opencode_provider_from_live(id)?;
|
||||||
@@ -374,7 +391,7 @@ impl ProviderService {
|
|||||||
/// c. Update database is_current (as default for new devices)
|
/// c. Update database is_current (as default for new devices)
|
||||||
/// d. Write target provider config to live files
|
/// d. Write target provider config to live files
|
||||||
/// e. Sync MCP configuration
|
/// 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
|
// Check if provider exists
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let _provider = providers
|
let _provider = providers
|
||||||
@@ -386,6 +403,13 @@ impl ProviderService {
|
|||||||
return Self::switch_normal(state, app_type, id, &providers);
|
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
|
// Check if proxy takeover mode is active AND proxy server is actually running
|
||||||
// Both conditions must be true to use hot-switch mode
|
// Both conditions must be true to use hot-switch mode
|
||||||
// Use blocking wait since this is a sync function
|
// Use blocking wait since this is a sync function
|
||||||
@@ -439,7 +463,7 @@ impl ProviderService {
|
|||||||
|
|
||||||
// Note: No Live config write, no MCP sync
|
// Note: No Live config write, no MCP sync
|
||||||
// The proxy server will route requests to the new provider via is_current
|
// 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
|
// Normal mode: full switch with Live config write
|
||||||
@@ -452,17 +476,38 @@ impl ProviderService {
|
|||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
id: &str,
|
id: &str,
|
||||||
providers: &indexmap::IndexMap<String, Provider>,
|
providers: &indexmap::IndexMap<String, Provider>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<SwitchResult, AppError> {
|
||||||
let provider = providers
|
let provider = providers
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
|
|
||||||
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
|
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") {
|
||||||
state.db.set_omo_provider_current(app_type.as_str(), id)?;
|
state
|
||||||
crate::services::OmoService::write_config_to_file(state)?;
|
.db
|
||||||
return Ok(());
|
.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
|
// Backfill: Backfill current live config to current provider
|
||||||
// Use effective current provider (validated existence) to ensure backfill targets valid 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)?;
|
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
|
||||||
@@ -475,9 +520,17 @@ impl ProviderService {
|
|||||||
// Only backfill when switching to a different provider
|
// Only backfill when switching to a different provider
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||||
current_provider.settings_config = live_config;
|
// Only extract key fields from live config for backfill
|
||||||
// Ignore backfill failure, don't affect switch flow
|
current_provider.settings_config =
|
||||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
backfill_key_fields(&app_type, &live_config);
|
||||||
|
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}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,13 +546,10 @@ impl ProviderService {
|
|||||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
// Sync to live (partial merge: only key fields, preserving user settings)
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_partial(&app_type, provider)?;
|
||||||
|
|
||||||
// Sync MCP
|
Ok(result)
|
||||||
McpService::sync_all_enabled(state)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync current provider to live configuration (re-export)
|
/// Sync current provider to live configuration (re-export)
|
||||||
@@ -507,222 +557,6 @@ impl ProviderService {
|
|||||||
sync_current_to_live(state)
|
sync_current_to_live(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract common config snippet from current provider
|
|
||||||
///
|
|
||||||
/// Extracts the current provider's configuration and removes provider-specific fields
|
|
||||||
/// (API keys, model settings, endpoints) to create a reusable common config snippet.
|
|
||||||
pub fn extract_common_config_snippet(
|
|
||||||
state: &AppState,
|
|
||||||
app_type: AppType,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
// Get current provider
|
|
||||||
let current_id = Self::current(state, app_type.clone())?;
|
|
||||||
if current_id.is_empty() {
|
|
||||||
return Err(AppError::Message("No current provider".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
|
||||||
let provider = providers
|
|
||||||
.get(¤t_id)
|
|
||||||
.ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?;
|
|
||||||
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
|
||||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
|
||||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
|
||||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config snippet from a config value (e.g. editor content).
|
|
||||||
pub fn extract_common_config_snippet_from_settings(
|
|
||||||
app_type: AppType,
|
|
||||||
settings_config: &Value,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
|
||||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
|
||||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
|
||||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for Claude (JSON format)
|
|
||||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Fields to exclude from common config
|
|
||||||
const ENV_EXCLUDES: &[&str] = &[
|
|
||||||
// Auth
|
|
||||||
"ANTHROPIC_API_KEY",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN",
|
|
||||||
// Models (5 fields)
|
|
||||||
"ANTHROPIC_MODEL",
|
|
||||||
"ANTHROPIC_REASONING_MODEL",
|
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
||||||
// Endpoint
|
|
||||||
"ANTHROPIC_BASE_URL",
|
|
||||||
];
|
|
||||||
|
|
||||||
const TOP_LEVEL_EXCLUDES: &[&str] = &[
|
|
||||||
"apiBaseUrl",
|
|
||||||
// Legacy model fields
|
|
||||||
"primaryModel",
|
|
||||||
"smallFastModel",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Remove env fields
|
|
||||||
if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
|
||||||
for key in ENV_EXCLUDES {
|
|
||||||
env.remove(*key);
|
|
||||||
}
|
|
||||||
// If env is empty after removal, remove the env object itself
|
|
||||||
if env.is_empty() {
|
|
||||||
config.as_object_mut().map(|obj| obj.remove("env"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove top-level fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
for key in TOP_LEVEL_EXCLUDES {
|
|
||||||
obj.remove(*key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if result is empty
|
|
||||||
if config.as_object().is_none_or(|obj| obj.is_empty()) {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&config)
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for Codex (TOML format)
|
|
||||||
fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// Codex config is stored as { "auth": {...}, "config": "toml string" }
|
|
||||||
let config_toml = settings
|
|
||||||
.get("config")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if config_toml.is_empty() {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut doc = config_toml
|
|
||||||
.parse::<toml_edit::DocumentMut>()
|
|
||||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
|
||||||
|
|
||||||
// Remove provider-specific fields.
|
|
||||||
let root = doc.as_table_mut();
|
|
||||||
root.remove("model");
|
|
||||||
root.remove("model_provider");
|
|
||||||
// Legacy/alt formats might use a top-level base_url.
|
|
||||||
root.remove("base_url");
|
|
||||||
|
|
||||||
// Remove entire model_providers table (provider-specific configuration)
|
|
||||||
root.remove("model_providers");
|
|
||||||
|
|
||||||
// Clean up multiple empty lines (keep at most one blank line).
|
|
||||||
let mut cleaned = String::new();
|
|
||||||
let mut blank_run = 0usize;
|
|
||||||
for line in doc.to_string().lines() {
|
|
||||||
if line.trim().is_empty() {
|
|
||||||
blank_run += 1;
|
|
||||||
if blank_run <= 1 {
|
|
||||||
cleaned.push('\n');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
blank_run = 0;
|
|
||||||
cleaned.push_str(line);
|
|
||||||
cleaned.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(cleaned.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for Gemini (JSON format)
|
|
||||||
///
|
|
||||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
|
||||||
/// - GOOGLE_GEMINI_BASE_URL
|
|
||||||
/// - GEMINI_API_KEY
|
|
||||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
let env = settings.get("env").and_then(|v| v.as_object());
|
|
||||||
|
|
||||||
let mut snippet = serde_json::Map::new();
|
|
||||||
if let Some(env) = env {
|
|
||||||
for (key, value) in env {
|
|
||||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Value::String(v) = value else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let trimmed = v.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if snippet.is_empty() {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for OpenCode (JSON format)
|
|
||||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// OpenCode uses a different config structure with npm, options, models
|
|
||||||
// For common config, we exclude provider-specific fields like apiKey
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Remove provider-specific fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
|
||||||
options.remove("apiKey");
|
|
||||||
options.remove("baseURL");
|
|
||||||
}
|
|
||||||
// Keep npm and models as they might be common
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&config)
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for OpenClaw (JSON format)
|
|
||||||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
|
||||||
// For common config, we exclude provider-specific fields like apiKey
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Remove provider-specific fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
obj.remove("apiKey");
|
|
||||||
obj.remove("baseUrl");
|
|
||||||
// Keep api and models as they might be common
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&config)
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import default configuration from live files (re-export)
|
/// Import default configuration from live files (re-export)
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||||
@@ -735,6 +569,11 @@ impl ProviderService {
|
|||||||
read_live_settings(app_type)
|
read_live_settings(app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Patch Claude live settings directly (user-level preferences)
|
||||||
|
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||||
|
live::patch_claude_live(patch)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get custom endpoints list (re-export)
|
/// Get custom endpoints list (re-export)
|
||||||
pub fn get_custom_endpoints(
|
pub fn get_custom_endpoints(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -830,10 +669,6 @@ impl ProviderService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
|
||||||
write_gemini_live(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::database::Database;
|
|||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::services::provider::write_live_snapshot;
|
use crate::services::provider::write_live_partial;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
write_live_snapshot(app_type, provider)
|
write_live_partial(app_type, provider)
|
||||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ pub mod terminal;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use providers::{claude, codex};
|
use providers::{claude, codex, gemini, openclaw, opencode};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -37,9 +37,27 @@ pub struct SessionMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
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();
|
let mut sessions = Vec::new();
|
||||||
sessions.extend(codex::scan_sessions());
|
sessions.extend(r1);
|
||||||
sessions.extend(claude::scan_sessions());
|
sessions.extend(r2);
|
||||||
|
sessions.extend(r3);
|
||||||
|
sessions.extend(r4);
|
||||||
|
sessions.extend(r5);
|
||||||
|
|
||||||
sessions.sort_by(|a, b| {
|
sessions.sort_by(|a, b| {
|
||||||
let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0);
|
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 {
|
match provider_id {
|
||||||
"codex" => codex::load_messages(path),
|
"codex" => codex::load_messages(path),
|
||||||
"claude" => claude::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}")),
|
_ => Err(format!("Unsupported provider: {provider_id}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use serde_json::Value;
|
|||||||
use crate::config::get_claude_config_dir;
|
use crate::config::get_claude_config_dir;
|
||||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
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";
|
const PROVIDER_ID: &str = "claude";
|
||||||
|
|
||||||
@@ -73,60 +75,61 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = File::open(path).ok()?;
|
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||||
let reader = BufReader::new(file);
|
|
||||||
|
|
||||||
let mut session_id: Option<String> = None;
|
let mut session_id: Option<String> = None;
|
||||||
let mut project_dir: Option<String> = None;
|
let mut project_dir: Option<String> = None;
|
||||||
let mut created_at: Option<i64> = 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() {
|
// Extract metadata from head lines
|
||||||
let line = match line {
|
for line in &head {
|
||||||
Ok(value) => value,
|
let value: Value = match serde_json::from_str(line) {
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let value: Value = match serde_json::from_str(&line) {
|
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if session_id.is_none() {
|
if session_id.is_none() {
|
||||||
session_id = value
|
session_id = value
|
||||||
.get("sessionId")
|
.get("sessionId")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if project_dir.is_none() {
|
if project_dir.is_none() {
|
||||||
project_dir = value
|
project_dir = value
|
||||||
.get("cwd")
|
.get("cwd")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
}
|
}
|
||||||
|
if created_at.is_none() {
|
||||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
if created_at.is_none() {
|
|
||||||
created_at = Some(ts);
|
|
||||||
}
|
|
||||||
last_active_at = Some(ts);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if value.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
// Extract last_active_at and summary from tail lines (reverse order)
|
||||||
continue;
|
let mut last_active_at: Option<i64> = None;
|
||||||
}
|
let mut summary: Option<String> = None;
|
||||||
|
|
||||||
let message = match value.get("message") {
|
for line in tail.iter().rev() {
|
||||||
Some(message) => message,
|
let value: Value = match serde_json::from_str(line) {
|
||||||
None => continue,
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
if last_active_at.is_none() {
|
||||||
let text = message.get("content").map(extract_text).unwrap_or_default();
|
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
if text.trim().is_empty() {
|
}
|
||||||
continue;
|
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));
|
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -8,10 +9,17 @@ use serde_json::Value;
|
|||||||
use crate::codex_config::get_codex_config_dir;
|
use crate::codex_config::get_codex_config_dir;
|
||||||
use crate::session_manager::{SessionMessage, SessionMeta};
|
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";
|
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> {
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
let root = get_codex_config_dir().join("sessions");
|
let root = get_codex_config_dir().join("sessions");
|
||||||
let mut files = Vec::new();
|
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> {
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||||
let file = File::open(path).ok()?;
|
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||||
let reader = BufReader::new(file);
|
|
||||||
|
|
||||||
let mut session_id: Option<String> = None;
|
let mut session_id: Option<String> = None;
|
||||||
let mut project_dir: Option<String> = None;
|
let mut project_dir: Option<String> = None;
|
||||||
let mut created_at: Option<i64> = 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() {
|
// Extract metadata from head lines
|
||||||
let line = match line {
|
for line in &head {
|
||||||
Ok(value) => value,
|
let value: Value = match serde_json::from_str(line) {
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let value: Value = match serde_json::from_str(&line) {
|
|
||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
if created_at.is_none() {
|
||||||
if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) {
|
created_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
if created_at.is_none() {
|
|
||||||
created_at = Some(ts);
|
|
||||||
}
|
|
||||||
last_active_at = Some(ts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
if value.get("type").and_then(Value::as_str) == Some("session_meta") {
|
||||||
if let Some(payload) = value.get("payload") {
|
if let Some(payload) = value.get("payload") {
|
||||||
if session_id.is_none() {
|
if session_id.is_none() {
|
||||||
@@ -118,27 +115,34 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|||||||
created_at.get_or_insert(ts);
|
created_at.get_or_insert(ts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if value.get("type").and_then(Value::as_str) != Some("response_item") {
|
// Extract last_active_at and summary from tail lines (reverse order)
|
||||||
continue;
|
let mut last_active_at: Option<i64> = None;
|
||||||
}
|
let mut summary: Option<String> = None;
|
||||||
|
|
||||||
let payload = match value.get("payload") {
|
for line in tail.iter().rev() {
|
||||||
Some(payload) => payload,
|
let value: Value = match serde_json::from_str(line) {
|
||||||
None => continue,
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
if last_active_at.is_none() {
|
||||||
if payload.get("type").and_then(Value::as_str) != Some("message") {
|
last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
if summary.is_none() && value.get("type").and_then(Value::as_str) == Some("response_item") {
|
||||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
if let Some(payload) = value.get("payload") {
|
||||||
if text.trim().is_empty() {
|
if payload.get("type").and_then(Value::as_str) == Some("message") {
|
||||||
continue;
|
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));
|
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> {
|
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||||
let file_name = path.file_name()?.to_string_lossy();
|
let file_name = path.file_name()?.to_string_lossy();
|
||||||
let re =
|
UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||||
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
||||||
.ok()?;
|
|
||||||
re.find(&file_name).map(|mat| mat.as_str().to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
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 claude;
|
||||||
pub mod codex;
|
pub mod codex;
|
||||||
|
pub mod gemini;
|
||||||
|
pub mod openclaw;
|
||||||
|
pub mod opencode;
|
||||||
mod utils;
|
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 chrono::{DateTime, FixedOffset};
|
||||||
use serde_json::Value;
|
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> {
|
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
|
||||||
let raw = value.as_str()?;
|
let raw = value.as_str()?;
|
||||||
DateTime::parse_from_rfc3339(raw)
|
DateTime::parse_from_rfc3339(raw)
|
||||||
|
|||||||
@@ -187,6 +187,15 @@ pub struct AppSettings {
|
|||||||
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub silent_startup: bool,
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
|
||||||
@@ -236,6 +245,14 @@ pub struct AppSettings {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub webdav_backup: Option<serde_json::Value>,
|
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"
|
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||||
@@ -262,6 +279,9 @@ impl Default for AppSettings {
|
|||||||
skip_claude_onboarding: false,
|
skip_claude_onboarding: false,
|
||||||
launch_on_startup: false,
|
launch_on_startup: false,
|
||||||
silent_startup: false,
|
silent_startup: false,
|
||||||
|
enable_local_proxy: false,
|
||||||
|
proxy_confirmed: None,
|
||||||
|
usage_confirmed: None,
|
||||||
language: None,
|
language: None,
|
||||||
visible_apps: None,
|
visible_apps: None,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
@@ -277,6 +297,8 @@ impl Default for AppSettings {
|
|||||||
skill_sync_method: SyncMethod::default(),
|
skill_sync_method: SyncMethod::default(),
|
||||||
webdav_sync: None,
|
webdav_sync: None,
|
||||||
webdav_backup: None,
|
webdav_backup: None,
|
||||||
|
backup_interval_hours: None,
|
||||||
|
backup_retain_count: None,
|
||||||
preferred_terminal: None,
|
preferred_terminal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,6 +633,33 @@ pub fn get_skill_sync_method() -> SyncMethod {
|
|||||||
.skill_sync_method
|
.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 show_main: &'static str,
|
||||||
pub no_provider_hint: &'static str,
|
pub no_provider_hint: &'static str,
|
||||||
pub quit: &'static str,
|
pub quit: &'static str,
|
||||||
pub auto_label: &'static str,
|
pub _auto_label: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrayTexts {
|
impl TrayTexts {
|
||||||
@@ -25,20 +25,20 @@ impl TrayTexts {
|
|||||||
show_main: "Open main window",
|
show_main: "Open main window",
|
||||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||||
quit: "Quit",
|
quit: "Quit",
|
||||||
auto_label: "Auto (Failover)",
|
_auto_label: "Auto (Failover)",
|
||||||
},
|
},
|
||||||
"ja" => Self {
|
"ja" => Self {
|
||||||
show_main: "メインウィンドウを開く",
|
show_main: "メインウィンドウを開く",
|
||||||
no_provider_hint:
|
no_provider_hint:
|
||||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||||
quit: "終了",
|
quit: "終了",
|
||||||
auto_label: "自動 (フェイルオーバー)",
|
_auto_label: "自動 (フェイルオーバー)",
|
||||||
},
|
},
|
||||||
_ => Self {
|
_ => Self {
|
||||||
show_main: "打开主界面",
|
show_main: "打开主界面",
|
||||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||||
quit: "退出",
|
quit: "退出",
|
||||||
auto_label: "自动 (故障转移)",
|
_auto_label: "自动 (故障转移)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ fn append_provider_section<'a>(
|
|||||||
manager: Option<&crate::provider::ProviderManager>,
|
manager: Option<&crate::provider::ProviderManager>,
|
||||||
section: &TrayAppSection,
|
section: &TrayAppSection,
|
||||||
tray_texts: &TrayTexts,
|
tray_texts: &TrayTexts,
|
||||||
app_state: &AppState,
|
_app_state: &AppState,
|
||||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||||
let Some(manager) = manager else {
|
let Some(manager) = manager else {
|
||||||
return Ok(menu_builder);
|
return Ok(menu_builder);
|
||||||
@@ -119,22 +119,9 @@ fn append_provider_section<'a>(
|
|||||||
return Ok(menu_builder.item(&empty_hint));
|
return Ok(menu_builder.item(&empty_hint));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 proxy 状态,决定 Auto 是否选中
|
// Auto (Failover) menu item is hidden from tray; the feature is still
|
||||||
let (proxy_enabled, auto_failover) =
|
// accessible from the Settings page. Keep the surrounding code intact so
|
||||||
app_state.db.get_proxy_flags_sync(section.app_type.as_str());
|
// it can be re-enabled easily in the future.
|
||||||
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);
|
|
||||||
|
|
||||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||||
@@ -156,8 +143,7 @@ fn append_provider_section<'a>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (id, provider) in sorted_providers {
|
for (id, provider) in sorted_providers {
|
||||||
// Auto 模式下所有供应商都不选中
|
let is_current = manager.current == *id;
|
||||||
let is_current = !auto_mode && manager.current == *id;
|
|
||||||
let item = CheckMenuItem::with_id(
|
let item = CheckMenuItem::with_id(
|
||||||
app,
|
app,
|
||||||
format!("{}{}", section.prefix, id),
|
format!("{}{}", section.prefix, id),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.10.3",
|
"version": "3.11.0",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ use serde_json::json;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
|
||||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
|
|
||||||
Provider, ProviderMeta,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
@@ -13,132 +10,6 @@ use support::{
|
|||||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_claude_provider_writes_live_settings() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
let provider_config = json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
|
||||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"displayName": "Test Provider"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"prov-1".to_string(),
|
|
||||||
"Test Claude".to_string(),
|
|
||||||
provider_config.clone(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Claude)
|
|
||||||
.expect("claude manager");
|
|
||||||
manager.providers.insert("prov-1".to_string(), provider);
|
|
||||||
manager.current = "prov-1".to_string();
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
|
||||||
|
|
||||||
let settings_path = get_claude_settings_path();
|
|
||||||
assert!(
|
|
||||||
settings_path.exists(),
|
|
||||||
"live settings should be written to {}",
|
|
||||||
settings_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
|
||||||
assert_eq!(live_value, provider_config);
|
|
||||||
|
|
||||||
// 确认 SSOT 中的供应商也同步了最新内容
|
|
||||||
let updated = config
|
|
||||||
.get_manager(&AppType::Claude)
|
|
||||||
.and_then(|m| m.providers.get("prov-1"))
|
|
||||||
.expect("provider in config");
|
|
||||||
assert_eq!(updated.settings_config, provider_config);
|
|
||||||
|
|
||||||
// 额外确认写入位置位于测试 HOME 下
|
|
||||||
assert!(
|
|
||||||
settings_path.starts_with(home),
|
|
||||||
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_codex_provider_writes_auth_and_config() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
|
|
||||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
|
||||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
|
||||||
|
|
||||||
let provider_config = json!({
|
|
||||||
"auth": {
|
|
||||||
"OPENAI_API_KEY": "codex-key"
|
|
||||||
},
|
|
||||||
"config": r#"base_url = "https://codex.test""#
|
|
||||||
});
|
|
||||||
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"codex-1".to_string(),
|
|
||||||
"Codex Test".to_string(),
|
|
||||||
provider_config.clone(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Codex)
|
|
||||||
.expect("codex manager");
|
|
||||||
manager.providers.insert("codex-1".to_string(), provider);
|
|
||||||
manager.current = "codex-1".to_string();
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
|
||||||
|
|
||||||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
|
||||||
let config_path = cc_switch_lib::get_codex_config_path();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
auth_path.exists(),
|
|
||||||
"auth.json should exist at {}",
|
|
||||||
auth_path.display()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
config_path.exists(),
|
|
||||||
"config.toml should exist at {}",
|
|
||||||
config_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
|
||||||
assert_eq!(
|
|
||||||
auth_value,
|
|
||||||
provider_config.get("auth").cloned().expect("auth object")
|
|
||||||
);
|
|
||||||
|
|
||||||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
|
||||||
// 验证基础配置正确写入
|
|
||||||
assert!(
|
|
||||||
toml_text.contains("base_url"),
|
|
||||||
"config.toml should contain base_url from provider config"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当前供应商应同步最新 config 文本
|
|
||||||
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
|
||||||
let synced = manager.providers.get("codex-1").expect("codex provider");
|
|
||||||
let synced_cfg = synced
|
|
||||||
.settings_config
|
|
||||||
.get("config")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.expect("config string");
|
|
||||||
assert_eq!(synced_cfg, toml_text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_enabled_to_codex_writes_enabled_servers() {
|
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
@@ -338,46 +209,6 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_codex_provider_missing_auth_returns_error() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"codex-missing-auth".to_string(),
|
|
||||||
"No Auth".to_string(),
|
|
||||||
json!({
|
|
||||||
"config": "model = \"test\""
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Codex)
|
|
||||||
.expect("codex manager");
|
|
||||||
manager.providers.insert(provider.id.clone(), provider);
|
|
||||||
manager.current = "codex-missing-auth".to_string();
|
|
||||||
|
|
||||||
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect_err("sync should fail when auth missing");
|
|
||||||
match err {
|
|
||||||
cc_switch_lib::AppError::Config(msg) => {
|
|
||||||
assert!(msg.contains("auth"), "error message should mention auth");
|
|
||||||
}
|
|
||||||
other => panic!("unexpected error variant: {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认未产生任何 live 配置文件
|
|
||||||
assert!(
|
|
||||||
!cc_switch_lib::get_codex_auth_path().exists(),
|
|
||||||
"auth.json should not be created on failure"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!cc_switch_lib::get_codex_config_path().exists(),
|
|
||||||
"config.toml should not be created on failure"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_codex_live_atomic_persists_auth_and_config() {
|
fn write_codex_live_atomic_persists_auth_and_config() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
@@ -816,107 +647,6 @@ fn create_backup_retains_only_latest_entries() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_gemini_packycode_sets_security_selected_type() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
{
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Gemini)
|
|
||||||
.expect("gemini manager");
|
|
||||||
manager.current = "packy-1".to_string();
|
|
||||||
manager.providers.insert(
|
|
||||||
"packy-1".to_string(),
|
|
||||||
Provider::with_id(
|
|
||||||
"packy-1".to_string(),
|
|
||||||
"PackyCode".to_string(),
|
|
||||||
json!({
|
|
||||||
"env": {
|
|
||||||
"GEMINI_API_KEY": "pk-key",
|
|
||||||
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Some("https://www.packyapi.com".to_string()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect("syncing gemini live should succeed");
|
|
||||||
|
|
||||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
|
||||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
|
||||||
assert!(
|
|
||||||
gemini_settings.exists(),
|
|
||||||
"Gemini settings.json should exist at {}",
|
|
||||||
gemini_settings.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
|
|
||||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
|
|
||||||
assert_eq!(
|
|
||||||
value
|
|
||||||
.pointer("/security/auth/selectedType")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
Some("gemini-api-key"),
|
|
||||||
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_gemini_google_official_sets_oauth_security() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
{
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Gemini)
|
|
||||||
.expect("gemini manager");
|
|
||||||
manager.current = "google-official".to_string();
|
|
||||||
let mut provider = Provider::with_id(
|
|
||||||
"google-official".to_string(),
|
|
||||||
"Google".to_string(),
|
|
||||||
json!({
|
|
||||||
"env": {}
|
|
||||||
}),
|
|
||||||
Some("https://ai.google.dev".to_string()),
|
|
||||||
);
|
|
||||||
provider.meta = Some(ProviderMeta {
|
|
||||||
partner_promotion_key: Some("google-official".to_string()),
|
|
||||||
..ProviderMeta::default()
|
|
||||||
});
|
|
||||||
manager
|
|
||||||
.providers
|
|
||||||
.insert("google-official".to_string(), provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect("syncing google official gemini should succeed");
|
|
||||||
|
|
||||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
|
||||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
|
||||||
assert!(
|
|
||||||
gemini_settings.exists(),
|
|
||||||
"Gemini settings should exist at {}",
|
|
||||||
gemini_settings.display()
|
|
||||||
);
|
|
||||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
|
||||||
let gemini_value: serde_json::Value =
|
|
||||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
|
||||||
assert_eq!(
|
|
||||||
gemini_value
|
|
||||||
.pointer("/security/auth/selectedType")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
Some("oauth-personal"),
|
|
||||||
"Gemini settings should record oauth-personal for Google Official"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn export_sql_writes_to_target_path() {
|
fn export_sql_writes_to_target_path() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
|||||||
@@ -100,9 +100,12 @@ command = "say"
|
|||||||
);
|
);
|
||||||
|
|
||||||
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||||
|
// With partial merge, only key fields (model, provider, model_providers) are
|
||||||
|
// merged into config.toml. The existing MCP section should be preserved.
|
||||||
|
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||||
assert!(
|
assert!(
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
config_text.contains("mcp_servers.legacy"),
|
||||||
"config.toml should contain synced MCP servers"
|
"config.toml should preserve existing MCP servers after partial merge"
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_id = app_state
|
let current_id = app_state
|
||||||
@@ -126,12 +129,9 @@ command = "say"
|
|||||||
.get("config")
|
.get("config")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
// 供应商配置应该包含在 live 文件中
|
// With partial merge, only key fields (model_provider, model, model_providers)
|
||||||
// 注意:live 文件还会包含 MCP 同步后的内容
|
// are written to the live file. MCP servers are synced separately.
|
||||||
assert!(
|
// The provider's stored config should still contain mcp_servers.latest.
|
||||||
config_text.contains("mcp_servers.latest"),
|
|
||||||
"live file should contain provider's original config"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
new_config_text.contains("mcp_servers.latest"),
|
new_config_text.contains("mcp_servers.latest"),
|
||||||
"provider snapshot should contain provider's original config"
|
"provider snapshot should contain provider's original config"
|
||||||
@@ -268,11 +268,22 @@ fn switch_provider_updates_claude_live_and_state() {
|
|||||||
let legacy_provider = providers
|
let legacy_provider = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
.expect("legacy provider still exists");
|
.expect("legacy provider still exists");
|
||||||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
// Backfill mechanism: before switching, the live config's key fields are
|
||||||
// 这保护了用户在 live 文件中的手动修改
|
// backfilled to the current provider. With partial merge, only key fields
|
||||||
|
// (auth, model, endpoint) are extracted — non-key fields like workspace
|
||||||
|
// are NOT included in the backfill.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
legacy_provider.settings_config, legacy_live,
|
legacy_provider
|
||||||
"previous provider should be backfilled with live config"
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("legacy-key"),
|
||||||
|
"previous provider should be backfilled with live auth key"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
legacy_provider.settings_config.get("workspace").is_none(),
|
||||||
|
"backfill should NOT include non-key fields like workspace"
|
||||||
);
|
);
|
||||||
|
|
||||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||||
|
|||||||
@@ -112,9 +112,12 @@ command = "say"
|
|||||||
|
|
||||||
let config_text =
|
let config_text =
|
||||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||||
|
// With partial merge, only key fields (model, provider, model_providers) are
|
||||||
|
// merged into config.toml. The existing MCP section should be preserved.
|
||||||
|
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||||
assert!(
|
assert!(
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
config_text.contains("mcp_servers.legacy"),
|
||||||
"config.toml should contain synced MCP servers"
|
"config.toml should preserve existing MCP servers after partial merge"
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_id = state
|
let current_id = state
|
||||||
@@ -143,11 +146,6 @@ command = "say"
|
|||||||
new_config_text.contains("mcp_servers.latest"),
|
new_config_text.contains("mcp_servers.latest"),
|
||||||
"provider config should contain original MCP servers"
|
"provider config should contain original MCP servers"
|
||||||
);
|
);
|
||||||
// live 文件额外包含同步的 MCP 服务器
|
|
||||||
assert!(
|
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
|
||||||
"live config should include synced MCP servers"
|
|
||||||
);
|
|
||||||
|
|
||||||
let legacy = providers
|
let legacy = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
@@ -414,9 +412,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
|||||||
let legacy_provider = providers
|
let legacy_provider = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
.expect("legacy provider still exists");
|
.expect("legacy provider still exists");
|
||||||
|
// With partial merge backfill, only key fields are extracted from live config
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
legacy_provider.settings_config, legacy_live,
|
legacy_provider
|
||||||
"previous provider should receive backfilled live config"
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("legacy-key"),
|
||||||
|
"previous provider should receive backfilled auth key"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
legacy_provider.settings_config.get("workspace").is_none(),
|
||||||
|
"backfill should NOT include non-key fields like workspace"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+276
-214
@@ -35,6 +35,7 @@ import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
|||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
|
import { useAutoCompact } from "@/hooks/useAutoCompact";
|
||||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||||
@@ -61,7 +62,10 @@ import { UniversalProviderPanel } from "@/components/universal";
|
|||||||
import { McpIcon } from "@/components/BrandIcons";
|
import { McpIcon } from "@/components/BrandIcons";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
|
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 WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
|
||||||
import EnvPanel from "@/components/openclaw/EnvPanel";
|
import EnvPanel from "@/components/openclaw/EnvPanel";
|
||||||
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
import ToolsPanel from "@/components/openclaw/ToolsPanel";
|
||||||
@@ -176,7 +180,10 @@ function App() {
|
|||||||
if (
|
if (
|
||||||
currentView === "sessions" &&
|
currentView === "sessions" &&
|
||||||
activeApp !== "claude" &&
|
activeApp !== "claude" &&
|
||||||
activeApp !== "codex"
|
activeApp !== "codex" &&
|
||||||
|
activeApp !== "opencode" &&
|
||||||
|
activeApp !== "openclaw" &&
|
||||||
|
activeApp !== "gemini"
|
||||||
) {
|
) {
|
||||||
setCurrentView("providers");
|
setCurrentView("providers");
|
||||||
}
|
}
|
||||||
@@ -194,6 +201,9 @@ function App() {
|
|||||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||||
|
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isToolbarCompact = useAutoCompact(toolbarRef);
|
||||||
|
|
||||||
const promptPanelRef = useRef<any>(null);
|
const promptPanelRef = useRef<any>(null);
|
||||||
const mcpPanelRef = useRef<any>(null);
|
const mcpPanelRef = useRef<any>(null);
|
||||||
const skillsPageRef = useRef<any>(null);
|
const skillsPageRef = useRef<any>(null);
|
||||||
@@ -220,7 +230,12 @@ function App() {
|
|||||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
const currentProviderId = data?.currentProviderId ?? "";
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
const hasSkillsSupport = true;
|
const hasSkillsSupport = true;
|
||||||
const hasSessionSupport = activeApp === "claude" || activeApp === "codex";
|
const hasSessionSupport =
|
||||||
|
activeApp === "claude" ||
|
||||||
|
activeApp === "codex" ||
|
||||||
|
activeApp === "opencode" ||
|
||||||
|
activeApp === "openclaw" ||
|
||||||
|
activeApp === "gemini";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
addProvider,
|
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(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -308,7 +340,8 @@ function App() {
|
|||||||
const off = await listen(
|
const off = await listen(
|
||||||
"webdav-sync-status-updated",
|
"webdav-sync-status-updated",
|
||||||
async (event) => {
|
async (event) => {
|
||||||
const payload = (event.payload ?? {}) as WebDavSyncStatusUpdatedPayload;
|
const payload = (event.payload ??
|
||||||
|
{}) as WebDavSyncStatusUpdatedPayload;
|
||||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
|
|
||||||
if (payload.source !== "auto" || payload.status !== "error") {
|
if (payload.source !== "auto" || payload.status !== "error") {
|
||||||
@@ -697,7 +730,7 @@ function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return <SessionManagerPage />;
|
return <SessionManagerPage key={activeApp} appId={activeApp} />;
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return <WorkspaceFilesPanel />;
|
return <WorkspaceFilesPanel />;
|
||||||
case "openclawEnv":
|
case "openclawEnv":
|
||||||
@@ -745,6 +778,11 @@ function App() {
|
|||||||
onDisableOmo={
|
onDisableOmo={
|
||||||
activeApp === "opencode" ? handleDisableOmo : undefined
|
activeApp === "opencode" ? handleDisableOmo : undefined
|
||||||
}
|
}
|
||||||
|
onDisableOmoSlim={
|
||||||
|
activeApp === "opencode"
|
||||||
|
? handleDisableOmoSlim
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
onConfigureUsage={setUsageProvider}
|
onConfigureUsage={setUsageProvider}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
onOpenWebsite={handleOpenWebsite}
|
||||||
@@ -927,228 +965,252 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 h-[32px]"
|
ref={toolbarRef}
|
||||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
className="flex flex-1 min-w-0 overflow-x-hidden justify-end items-center"
|
||||||
>
|
>
|
||||||
{currentView === "prompts" && (
|
<div
|
||||||
<Button
|
className="flex shrink-0 items-center gap-1.5"
|
||||||
variant="ghost"
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||||
size="sm"
|
>
|
||||||
onClick={() => promptPanelRef.current?.openAdd()}
|
{currentView === "prompts" && (
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t("prompts.add")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{currentView === "mcp" && (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => mcpPanelRef.current?.openImport()}
|
onClick={() => promptPanelRef.current?.openAdd()}
|
||||||
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"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
{t("mcp.addMcp")}
|
{t("prompts.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
{currentView === "mcp" && (
|
||||||
{currentView === "skills" && (
|
<>
|
||||||
<>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => mcpPanelRef.current?.openImport()}
|
||||||
onClick={() =>
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
>
|
||||||
}
|
<Download className="w-4 h-4 mr-2" />
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
{t("mcp.importExisting")}
|
||||||
>
|
</Button>
|
||||||
<FolderArchive className="w-4 h-4 mr-2" />
|
<Button
|
||||||
{t("skills.installFromZip.button")}
|
variant="ghost"
|
||||||
</Button>
|
size="sm"
|
||||||
<Button
|
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||||
variant="ghost"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
size="sm"
|
>
|
||||||
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
{t("mcp.addMcp")}
|
||||||
>
|
</Button>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
</>
|
||||||
{t("skills.import")}
|
)}
|
||||||
</Button>
|
{currentView === "skills" && (
|
||||||
<Button
|
<>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setCurrentView("skillsDiscovery")}
|
size="sm"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
onClick={() =>
|
||||||
>
|
unifiedSkillsPanelRef.current?.openInstallFromZip()
|
||||||
<Search className="w-4 h-4 mr-2" />
|
}
|
||||||
{t("skills.discover")}
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
</Button>
|
>
|
||||||
</>
|
<FolderArchive className="w-4 h-4 mr-2" />
|
||||||
)}
|
{t("skills.installFromZip.button")}
|
||||||
{currentView === "skillsDiscovery" && (
|
</Button>
|
||||||
<>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
|
||||||
onClick={() => skillsPageRef.current?.refresh()}
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
>
|
||||||
>
|
<Download className="w-4 h-4 mr-2" />
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
{t("skills.import")}
|
||||||
{t("skills.refresh")}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setCurrentView("skillsDiscovery")}
|
||||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
>
|
||||||
>
|
<Search className="w-4 h-4 mr-2" />
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
{t("skills.discover")}
|
||||||
{t("skills.repoManager")}
|
</Button>
|
||||||
</Button>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{currentView === "skillsDiscovery" && (
|
||||||
{currentView === "providers" && (
|
<>
|
||||||
<>
|
<Button
|
||||||
{activeApp !== "opencode" && activeApp !== "openclaw" && (
|
variant="ghost"
|
||||||
<>
|
size="sm"
|
||||||
<ProxyToggle activeApp={activeApp} />
|
onClick={() => skillsPageRef.current?.refresh()}
|
||||||
<div
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
className={cn(
|
>
|
||||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
isCurrentAppTakeoverActive
|
{t("skills.refresh")}
|
||||||
? "opacity-100 max-w-[100px] scale-100"
|
</Button>
|
||||||
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
<Button
|
||||||
)}
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<FailoverToggle activeApp={activeApp} />
|
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||||
</div>
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
</>
|
>
|
||||||
)}
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
{t("skills.repoManager")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{currentView === "providers" && (
|
||||||
|
<>
|
||||||
|
{activeApp !== "opencode" &&
|
||||||
|
activeApp !== "openclaw" &&
|
||||||
|
settingsData?.enableLocalProxy && (
|
||||||
|
<>
|
||||||
|
<ProxyToggle activeApp={activeApp} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
|
isCurrentAppTakeoverActive
|
||||||
|
? "opacity-100 max-w-[100px] scale-100"
|
||||||
|
: "opacity-0 max-w-0 scale-75 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FailoverToggle activeApp={activeApp} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<AppSwitcher
|
<AppSwitcher
|
||||||
activeApp={activeApp}
|
activeApp={activeApp}
|
||||||
onSwitch={setActiveApp}
|
onSwitch={setActiveApp}
|
||||||
visibleApps={visibleApps}
|
visibleApps={visibleApps}
|
||||||
compact={
|
compact={isToolbarCompact}
|
||||||
isCurrentAppTakeoverActive &&
|
/>
|
||||||
Object.values(visibleApps).filter(Boolean).length >= 4
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||||
{activeApp === "openclaw" ? (
|
<AnimatePresence mode="wait">
|
||||||
<>
|
<motion.div
|
||||||
<Button
|
key={activeApp === "openclaw" ? "openclaw" : "default"}
|
||||||
variant="ghost"
|
className="flex items-center gap-1"
|
||||||
size="sm"
|
initial={{ opacity: 0 }}
|
||||||
onClick={() => setCurrentView("workspace")}
|
animate={{ opacity: 1 }}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
exit={{ opacity: 0 }}
|
||||||
title={t("workspace.manage")}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4" />
|
{activeApp === "openclaw" ? (
|
||||||
</Button>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("openclawEnv")}
|
onClick={() => setCurrentView("workspace")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("openclaw.env.title")}
|
title={t("workspace.manage")}
|
||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4" />
|
<FolderOpen className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("openclawTools")}
|
onClick={() => setCurrentView("openclawEnv")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("openclaw.tools.title")}
|
title={t("openclaw.env.title")}
|
||||||
>
|
>
|
||||||
<Shield className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("openclawAgents")}
|
onClick={() => setCurrentView("openclawTools")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("openclaw.agents.title")}
|
title={t("openclaw.tools.title")}
|
||||||
>
|
>
|
||||||
<Cpu className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
<Button
|
||||||
) : (
|
variant="ghost"
|
||||||
<>
|
size="sm"
|
||||||
<Button
|
onClick={() => setCurrentView("openclawAgents")}
|
||||||
variant="ghost"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
size="sm"
|
title={t("openclaw.agents.title")}
|
||||||
onClick={() => setCurrentView("skills")}
|
>
|
||||||
className={cn(
|
<Cpu className="w-4 h-4" />
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
</Button>
|
||||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
<Button
|
||||||
hasSkillsSupport
|
variant="ghost"
|
||||||
? "opacity-100 w-8 scale-100 px-2"
|
size="sm"
|
||||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
</motion.div>
|
||||||
>
|
</AnimatePresence>
|
||||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
</div>
|
||||||
</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
|
<Button
|
||||||
onClick={() => setIsAddOpen(true)}
|
onClick={() => setIsAddOpen(true)}
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`ml-2 ${addActionButtonClass}`}
|
className={`ml-2 ${addActionButtonClass}`}
|
||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import type { VisibleApps } from "@/types";
|
import type { VisibleApps } from "@/types";
|
||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AppSwitcherProps {
|
interface AppSwitcherProps {
|
||||||
activeApp: AppId;
|
activeApp: AppId;
|
||||||
@@ -52,18 +53,28 @@ export function AppSwitcher({
|
|||||||
key={app}
|
key={app}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch(app)}
|
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
|
activeApp === app
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "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
|
<ProviderIcon
|
||||||
icon={appIconName[app]}
|
icon={appIconName[app]}
|
||||||
name={appDisplayName[app]}
|
name={appDisplayName[app]}
|
||||||
size={iconSize}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle, Info } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
@@ -16,6 +16,7 @@ interface ConfirmDialogProps {
|
|||||||
message: string;
|
message: string;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
variant?: "destructive" | "info";
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -26,11 +27,16 @@ export function ConfirmDialog({
|
|||||||
message,
|
message,
|
||||||
confirmText,
|
confirmText,
|
||||||
cancelText,
|
cancelText,
|
||||||
|
variant = "destructive",
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
@@ -43,7 +49,7 @@ export function ConfirmDialog({
|
|||||||
<DialogContent className="max-w-sm" zIndex="alert">
|
<DialogContent className="max-w-sm" zIndex="alert">
|
||||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
<IconComponent className={iconClass} />
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
||||||
@@ -54,7 +60,10 @@ export function ConfirmDialog({
|
|||||||
<Button variant="outline" onClick={onCancel}>
|
<Button variant="outline" onClick={onCancel}>
|
||||||
{cancelText || t("common.cancel")}
|
{cancelText || t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={onConfirm}>
|
<Button
|
||||||
|
variant={variant === "info" ? "default" : "destructive"}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
{confirmText || t("common.confirm")}
|
{confirmText || t("common.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -53,15 +53,12 @@ export function DeepLinkImportDialog() {
|
|||||||
const unlistenImport = listen<DeepLinkImportRequest>(
|
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||||
"deeplink-import",
|
"deeplink-import",
|
||||||
async (event) => {
|
async (event) => {
|
||||||
console.log("Deep link import event received:", event.payload);
|
|
||||||
|
|
||||||
// If config is present, merge it to get the complete configuration
|
// If config is present, merge it to get the complete configuration
|
||||||
if (event.payload.config || event.payload.configUrl) {
|
if (event.payload.config || event.payload.configUrl) {
|
||||||
try {
|
try {
|
||||||
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
||||||
event.payload,
|
event.payload,
|
||||||
);
|
);
|
||||||
console.log("Config merged successfully:", mergedRequest);
|
|
||||||
setRequest(mergedRequest);
|
setRequest(mergedRequest);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to merge config:", error);
|
console.error("Failed to merge config:", error);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { toast } from "sonner";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Provider, UsageScript, UsageData } from "@/types";
|
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 { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
|
||||||
import JsonEditor from "./JsonEditor";
|
import JsonEditor from "./JsonEditor";
|
||||||
import * as prettier from "prettier/standalone";
|
import * as prettier from "prettier/standalone";
|
||||||
@@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface UsageScriptModalProps {
|
interface UsageScriptModalProps {
|
||||||
@@ -112,6 +114,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: settingsData } = useSettingsQuery();
|
||||||
|
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
|
||||||
|
|
||||||
// 生成带国际化的预设模板
|
// 生成带国际化的预设模板
|
||||||
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
const PRESET_TEMPLATES = generatePresetTemplates(t);
|
||||||
@@ -247,6 +251,27 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [showAccessToken, setShowAccessToken] = 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 = () => {
|
const handleSave = () => {
|
||||||
if (script.enabled && !script.code.trim()) {
|
if (script.enabled && !script.code.trim()) {
|
||||||
toast.error(t("usageScript.scriptEmpty"));
|
toast.error(t("usageScript.scriptEmpty"));
|
||||||
@@ -436,9 +461,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
<Switch
|
<Switch
|
||||||
checked={script.enabled}
|
checked={script.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={handleEnableToggle}
|
||||||
setScript({ ...script, enabled: checked })
|
|
||||||
}
|
|
||||||
aria-label={t("usageScript.enableUsageQuery")}
|
aria-label={t("usageScript.enableUsageQuery")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -844,6 +867,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</FullScreenPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
|||||||
interface AppCountBarProps {
|
interface AppCountBarProps {
|
||||||
totalLabel: string;
|
totalLabel: string;
|
||||||
counts: Record<AppId, number>;
|
counts: Record<AppId, number>;
|
||||||
|
appIds?: AppId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
export const AppCountBar: React.FC<AppCountBarProps> = ({
|
||||||
totalLabel,
|
totalLabel,
|
||||||
counts,
|
counts,
|
||||||
|
appIds = APP_IDS,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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">
|
<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}
|
{totalLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||||||
{APP_IDS.map((app) => (
|
{appIds.map((app) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={app}
|
key={app}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ import { APP_IDS, APP_ICON_MAP } from "@/config/appConfig";
|
|||||||
interface AppToggleGroupProps {
|
interface AppToggleGroupProps {
|
||||||
apps: Record<AppId, boolean>;
|
apps: Record<AppId, boolean>;
|
||||||
onToggle: (app: AppId, enabled: boolean) => void;
|
onToggle: (app: AppId, enabled: boolean) => void;
|
||||||
|
appIds?: AppId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
export const AppToggleGroup: React.FC<AppToggleGroupProps> = ({
|
||||||
apps,
|
apps,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
appIds = APP_IDS,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<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 { label, icon, activeClass } = APP_ICON_MAP[app];
|
||||||
const enabled = apps[app];
|
const enabled = apps[app];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react";
|
|||||||
import { settingsApi } from "@/lib/api";
|
import { settingsApi } from "@/lib/api";
|
||||||
import { mcpPresets } from "@/config/mcpPresets";
|
import { mcpPresets } from "@/config/mcpPresets";
|
||||||
import { toast } from "sonner";
|
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 { AppCountBar } from "@/components/common/AppCountBar";
|
||||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
import { ListItemRow } from "@/components/common/ListItemRow";
|
||||||
@@ -58,7 +58,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
const enabledCounts = useMemo(() => {
|
const enabledCounts = useMemo(() => {
|
||||||
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||||
serverEntries.forEach(([_, server]) => {
|
serverEntries.forEach(([_, server]) => {
|
||||||
for (const app of APP_IDS) {
|
for (const app of MCP_SKILLS_APP_IDS) {
|
||||||
if (server.apps[app]) counts[app]++;
|
if (server.apps[app]) counts[app]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -136,6 +136,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
<AppCountBar
|
<AppCountBar
|
||||||
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
|
||||||
counts={enabledCounts}
|
counts={enabledCounts}
|
||||||
|
appIds={MCP_SKILLS_APP_IDS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||||
@@ -277,6 +278,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
<AppToggleGroup
|
<AppToggleGroup
|
||||||
apps={server.apps}
|
apps={server.apps}
|
||||||
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
|
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">
|
<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 { data: agentsData, isLoading } = useOpenClawAgentsDefaults();
|
||||||
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
const saveAgentsMutation = useSaveOpenClawAgentsDefaults();
|
||||||
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
|
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
|
||||||
const [primaryModel, setPrimaryModel] = useState("");
|
|
||||||
const [fallbacks, setFallbacks] = useState("");
|
const [fallbacks, setFallbacks] = useState("");
|
||||||
|
|
||||||
// Extra known fields from agents.defaults
|
// Extra known fields from agents.defaults
|
||||||
@@ -26,13 +25,15 @@ const AgentsDefaultsPanel: React.FC = () => {
|
|||||||
const [contextTokens, setContextTokens] = useState("");
|
const [contextTokens, setContextTokens] = useState("");
|
||||||
const [maxConcurrent, setMaxConcurrent] = 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(() => {
|
useEffect(() => {
|
||||||
// agentsData is undefined while loading, null when config section is absent
|
// agentsData is undefined while loading, null when config section is absent
|
||||||
if (agentsData === undefined) return;
|
if (agentsData === undefined) return;
|
||||||
setDefaults(agentsData);
|
setDefaults(agentsData);
|
||||||
|
|
||||||
if (agentsData) {
|
if (agentsData) {
|
||||||
setPrimaryModel(agentsData.model?.primary ?? "");
|
|
||||||
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
|
setFallbacks((agentsData.model?.fallbacks ?? []).join(", "));
|
||||||
|
|
||||||
// Extract known extra fields
|
// Extract known extra fields
|
||||||
@@ -48,17 +49,21 @@ const AgentsDefaultsPanel: React.FC = () => {
|
|||||||
// Preserve all unknown fields from original data
|
// Preserve all unknown fields from original data
|
||||||
const updated: OpenClawAgentsDefaults = { ...defaults };
|
const updated: OpenClawAgentsDefaults = { ...defaults };
|
||||||
|
|
||||||
// Model configuration
|
// Model configuration — primary is read-only, preserve original value
|
||||||
const fallbackList = fallbacks
|
const fallbackList = fallbacks
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (primaryModel.trim()) {
|
const origPrimary = defaults?.model?.primary;
|
||||||
|
if (origPrimary) {
|
||||||
updated.model = {
|
updated.model = {
|
||||||
primary: primaryModel.trim(),
|
primary: origPrimary,
|
||||||
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
|
...(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
|
// Optional fields
|
||||||
@@ -122,12 +127,9 @@ const AgentsDefaultsPanel: React.FC = () => {
|
|||||||
<Label className="mb-1.5 block">
|
<Label className="mb-1.5 block">
|
||||||
{t("openclaw.agents.primaryModel")}
|
{t("openclaw.agents.primaryModel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="h-9 px-3 flex items-center rounded-md border border-input bg-muted/50 font-mono text-xs text-muted-foreground">
|
||||||
value={primaryModel}
|
{primaryModel || t("openclaw.agents.notSet")}
|
||||||
onChange={(e) => setPrimaryModel(e.target.value)}
|
</div>
|
||||||
placeholder="provider/model-id"
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("openclaw.agents.primaryModelHint")}
|
{t("openclaw.agents.primaryModelHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export function EditProviderDialog({
|
|||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
showButtons={false}
|
showButtons={false}
|
||||||
|
isCurrent={liveSettings !== null}
|
||||||
/>
|
/>
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
Edit,
|
Edit,
|
||||||
Loader2,
|
// Loader2, // Hidden: stream check feature disabled
|
||||||
Minus,
|
Minus,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Terminal,
|
Terminal,
|
||||||
TestTube2,
|
// TestTube2, // Hidden: stream check feature disabled
|
||||||
Trash2,
|
Trash2,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -24,7 +24,6 @@ interface ProviderActionsProps {
|
|||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
isProxyTakeover?: boolean;
|
isProxyTakeover?: boolean;
|
||||||
isOmo?: boolean;
|
isOmo?: boolean;
|
||||||
isLastOmo?: boolean;
|
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
@@ -46,14 +45,13 @@ export function ProviderActions({
|
|||||||
appId,
|
appId,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
isInConfig = false,
|
isInConfig = false,
|
||||||
isTesting,
|
isTesting: _isTesting, // Hidden: stream check feature disabled
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
isOmo = false,
|
isOmo = false,
|
||||||
isLastOmo = false,
|
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onTest,
|
onTest: _onTest, // Hidden: stream check feature disabled
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
@@ -192,11 +190,7 @@ export function ProviderActions({
|
|||||||
|
|
||||||
const buttonState = getMainButtonState();
|
const buttonState = getMainButtonState();
|
||||||
|
|
||||||
const canDelete = isOmo
|
const canDelete = isOmo || isAdditiveMode ? true : !isCurrent;
|
||||||
? !(isLastOmo && isCurrent)
|
|
||||||
: isAdditiveMode
|
|
||||||
? true
|
|
||||||
: !isCurrent;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -207,7 +201,7 @@ export function ProviderActions({
|
|||||||
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
onClick={isDefaultModel ? undefined : onSetAsDefault}
|
||||||
disabled={isDefaultModel}
|
disabled={isDefaultModel}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[4.5rem] px-2.5",
|
"w-fit px-2.5",
|
||||||
isDefaultModel
|
isDefaultModel
|
||||||
? "bg-gray-200 text-muted-foreground dark:bg-gray-700 opacity-60 cursor-not-allowed"
|
? "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",
|
: "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" />
|
<Zap className="h-4 w-4" />
|
||||||
{isDefaultModel
|
{isDefaultModel
|
||||||
? t("provider.isDefault", { defaultValue: "默认" })
|
? t("provider.isDefault", { defaultValue: "当前默认" })
|
||||||
: t("provider.setAsDefault", { defaultValue: "启用" })}
|
: t("provider.setAsDefault", { defaultValue: "设为默认" })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -252,6 +246,7 @@ export function ProviderActions({
|
|||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Hidden: stream check feature disabled
|
||||||
{onTest && (
|
{onTest && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -268,6 +263,7 @@ export function ProviderActions({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
*/}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ interface ProviderCardProps {
|
|||||||
appId: AppId;
|
appId: AppId;
|
||||||
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json
|
||||||
isOmo?: boolean;
|
isOmo?: boolean;
|
||||||
isLastOmo?: boolean;
|
isOmoSlim?: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDisableOmo?: () => void;
|
onDisableOmo?: () => void;
|
||||||
|
onDisableOmoSlim?: () => void;
|
||||||
onConfigureUsage: (provider: Provider) => void;
|
onConfigureUsage: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
@@ -91,12 +92,13 @@ export function ProviderCard({
|
|||||||
appId,
|
appId,
|
||||||
isInConfig = true,
|
isInConfig = true,
|
||||||
isOmo = false,
|
isOmo = false,
|
||||||
isLastOmo = false,
|
isOmoSlim = false,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
onDisableOmo,
|
onDisableOmo,
|
||||||
|
onDisableOmoSlim,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -117,6 +119,10 @@ export function ProviderCard({
|
|||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
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 { data: health } = useProviderHealth(provider.id, appId);
|
||||||
|
|
||||||
const fallbackUrlText = t("provider.notConfigured", {
|
const fallbackUrlText = t("provider.notConfigured", {
|
||||||
@@ -186,11 +192,11 @@ export function ProviderCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否是"当前使用中"的供应商
|
// 判断是否是"当前使用中"的供应商
|
||||||
// - OMO 供应商:使用 isCurrent
|
// - OMO/OMO Slim 供应商:使用 isCurrent
|
||||||
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
// - 累加模式应用(OpenCode 非 OMO / OpenClaw):不存在"当前"概念,始终返回 false
|
||||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||||
// - 普通模式:isCurrent
|
// - 普通模式:isCurrent
|
||||||
const isActiveProvider = isOmo
|
const isActiveProvider = isAnyOmo
|
||||||
? isCurrent
|
? isCurrent
|
||||||
: appId === "opencode" || appId === "openclaw"
|
: appId === "opencode" || appId === "openclaw"
|
||||||
? false
|
? false
|
||||||
@@ -198,10 +204,10 @@ export function ProviderCard({
|
|||||||
? activeProviderId === provider.id
|
? activeProviderId === provider.id
|
||||||
: isCurrent;
|
: isCurrent;
|
||||||
|
|
||||||
const shouldUseGreen = !isOmo && isProxyTakeover && isActiveProvider;
|
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
|
||||||
const shouldUseBlue =
|
const shouldUseBlue =
|
||||||
(isOmo && isActiveProvider) ||
|
(isAnyOmo && isActiveProvider) ||
|
||||||
(!isOmo && !isProxyTakeover && isActiveProvider);
|
(!isAnyOmo && !isProxyTakeover && isActiveProvider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -265,6 +271,12 @@ export function ProviderCard({
|
|||||||
</span>
|
</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 && (
|
{isProxyRunning && isInFailoverQueue && health && (
|
||||||
<ProviderHealthBadge
|
<ProviderHealthBadge
|
||||||
consecutiveFailures={health.consecutive_failures}
|
consecutiveFailures={health.consecutive_failures}
|
||||||
@@ -372,8 +384,7 @@ export function ProviderCard({
|
|||||||
isInConfig={isInConfig}
|
isInConfig={isInConfig}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
isOmo={isOmo}
|
isOmo={isAnyOmo}
|
||||||
isLastOmo={isLastOmo}
|
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
onEdit={() => onEdit(provider)}
|
onEdit={() => onEdit(provider)}
|
||||||
onDuplicate={() => onDuplicate(provider)}
|
onDuplicate={() => onDuplicate(provider)}
|
||||||
@@ -385,7 +396,7 @@ export function ProviderCard({
|
|||||||
? () => onRemoveFromConfig(provider)
|
? () => onRemoveFromConfig(provider)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDisableOmo={onDisableOmo}
|
onDisableOmo={handleDisableAnyOmo}
|
||||||
onOpenTerminal={
|
onOpenTerminal={
|
||||||
onOpenTerminal ? () => onOpenTerminal(provider) : undefined
|
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 { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface ProviderEmptyStateProps {
|
interface ProviderEmptyStateProps {
|
||||||
onCreate?: () => void;
|
onCreate?: () => void;
|
||||||
|
onImport?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
export function ProviderEmptyState({
|
||||||
|
onCreate,
|
||||||
|
onImport,
|
||||||
|
}: ProviderEmptyStateProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -18,11 +22,19 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
|||||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
{t("provider.noProvidersDescription")}
|
{t("provider.noProvidersDescription")}
|
||||||
</p>
|
</p>
|
||||||
{onCreate && (
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
<Button className="mt-6" onClick={onCreate}>
|
{onImport && (
|
||||||
{t("provider.addProvider")}
|
<Button onClick={onImport}>
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { Provider } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { providersApi } from "@/lib/api/providers";
|
import { providersApi } from "@/lib/api/providers";
|
||||||
@@ -33,7 +34,10 @@ import {
|
|||||||
useAddToFailoverQueue,
|
useAddToFailoverQueue,
|
||||||
useRemoveFromFailoverQueue,
|
useRemoveFromFailoverQueue,
|
||||||
} from "@/lib/query/failover";
|
} from "@/lib/query/failover";
|
||||||
import { useCurrentOmoProviderId, useOmoProviderCount } from "@/lib/query/omo";
|
import {
|
||||||
|
useCurrentOmoProviderId,
|
||||||
|
useCurrentOmoSlimProviderId,
|
||||||
|
} from "@/lib/query/omo";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -47,6 +51,7 @@ interface ProviderListProps {
|
|||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDisableOmo?: () => void;
|
onDisableOmo?: () => void;
|
||||||
|
onDisableOmoSlim?: () => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -68,6 +73,7 @@ export function ProviderList({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
onDisableOmo,
|
onDisableOmo,
|
||||||
|
onDisableOmoSlim,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -134,7 +140,7 @@ export function ProviderList({
|
|||||||
|
|
||||||
const isOpenCode = appId === "opencode";
|
const isOpenCode = appId === "opencode";
|
||||||
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode);
|
||||||
const { data: omoProviderCount } = useOmoProviderCount(isOpenCode);
|
const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode);
|
||||||
|
|
||||||
const getFailoverPriority = useCallback(
|
const getFailoverPriority = useCallback(
|
||||||
(providerId: string): number | undefined => {
|
(providerId: string): number | undefined => {
|
||||||
@@ -170,6 +176,23 @@ export function ProviderList({
|
|||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Import current live config as default provider
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: () => 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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
@@ -222,8 +245,17 @@ export function ProviderList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show import button for standard apps (not additive-mode apps like OpenCode/OpenClaw)
|
||||||
|
const showImportButton =
|
||||||
|
appId === "claude" || appId === "codex" || appId === "gemini";
|
||||||
|
|
||||||
if (sortedProviders.length === 0) {
|
if (sortedProviders.length === 0) {
|
||||||
return <ProviderEmptyState onCreate={onCreate} />;
|
return (
|
||||||
|
<ProviderEmptyState
|
||||||
|
onCreate={onCreate}
|
||||||
|
onImport={showImportButton ? () => importMutation.mutate() : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderProviderList = () => (
|
const renderProviderList = () => (
|
||||||
@@ -239,25 +271,31 @@ export function ProviderList({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredProviders.map((provider) => {
|
{filteredProviders.map((provider) => {
|
||||||
const isOmo = provider.category === "omo";
|
const isOmo = provider.category === "omo";
|
||||||
|
const isOmoSlim = provider.category === "omo-slim";
|
||||||
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
const isOmoCurrent = isOmo && provider.id === (currentOmoId || "");
|
||||||
|
const isOmoSlimCurrent =
|
||||||
|
isOmoSlim && provider.id === (currentOmoSlimId || "");
|
||||||
return (
|
return (
|
||||||
<SortableProviderCard
|
<SortableProviderCard
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
isCurrent={
|
isCurrent={
|
||||||
isOmo ? isOmoCurrent : provider.id === currentProviderId
|
isOmo
|
||||||
|
? isOmoCurrent
|
||||||
|
: isOmoSlim
|
||||||
|
? isOmoSlimCurrent
|
||||||
|
: provider.id === currentProviderId
|
||||||
}
|
}
|
||||||
appId={appId}
|
appId={appId}
|
||||||
isInConfig={isProviderInConfig(provider.id)}
|
isInConfig={isProviderInConfig(provider.id)}
|
||||||
isOmo={isOmo}
|
isOmo={isOmo}
|
||||||
isLastOmo={
|
isOmoSlim={isOmoSlim}
|
||||||
isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent
|
|
||||||
}
|
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRemoveFromConfig={onRemoveFromConfig}
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDisableOmo={onDisableOmo}
|
onDisableOmo={onDisableOmo}
|
||||||
|
onDisableOmoSlim={onDisableOmoSlim}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={onConfigureUsage}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
@@ -370,12 +408,13 @@ interface SortableProviderCardProps {
|
|||||||
appId: AppId;
|
appId: AppId;
|
||||||
isInConfig: boolean;
|
isInConfig: boolean;
|
||||||
isOmo: boolean;
|
isOmo: boolean;
|
||||||
isLastOmo: boolean;
|
isOmoSlim: boolean;
|
||||||
onSwitch: (provider: Provider) => void;
|
onSwitch: (provider: Provider) => void;
|
||||||
onEdit: (provider: Provider) => void;
|
onEdit: (provider: Provider) => void;
|
||||||
onDelete: (provider: Provider) => void;
|
onDelete: (provider: Provider) => void;
|
||||||
onRemoveFromConfig?: (provider: Provider) => void;
|
onRemoveFromConfig?: (provider: Provider) => void;
|
||||||
onDisableOmo?: () => void;
|
onDisableOmo?: () => void;
|
||||||
|
onDisableOmoSlim?: () => void;
|
||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onConfigureUsage?: (provider: Provider) => void;
|
onConfigureUsage?: (provider: Provider) => void;
|
||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
@@ -400,12 +439,13 @@ function SortableProviderCard({
|
|||||||
appId,
|
appId,
|
||||||
isInConfig,
|
isInConfig,
|
||||||
isOmo,
|
isOmo,
|
||||||
isLastOmo,
|
isOmoSlim,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRemoveFromConfig,
|
onRemoveFromConfig,
|
||||||
onDisableOmo,
|
onDisableOmo,
|
||||||
|
onDisableOmoSlim,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
@@ -444,12 +484,13 @@ function SortableProviderCard({
|
|||||||
appId={appId}
|
appId={appId}
|
||||||
isInConfig={isInConfig}
|
isInConfig={isInConfig}
|
||||||
isOmo={isOmo}
|
isOmo={isOmo}
|
||||||
isLastOmo={isLastOmo}
|
isOmoSlim={isOmoSlim}
|
||||||
onSwitch={onSwitch}
|
onSwitch={onSwitch}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRemoveFromConfig={onRemoveFromConfig}
|
onRemoveFromConfig={onRemoveFromConfig}
|
||||||
onDisableOmo={onDisableOmo}
|
onDisableOmo={onDisableOmo}
|
||||||
|
onDisableOmoSlim={onDisableOmoSlim}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onConfigureUsage={
|
onConfigureUsage={
|
||||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
import { ApiKeySection, EndpointField } from "./shared";
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
import type {
|
||||||
|
ProviderCategory,
|
||||||
|
ClaudeApiFormat,
|
||||||
|
ClaudeApiKeyField,
|
||||||
|
} from "@/types";
|
||||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||||
|
|
||||||
interface EndpointCandidate {
|
interface EndpointCandidate {
|
||||||
@@ -68,6 +72,10 @@ interface ClaudeFormFieldsProps {
|
|||||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||||
apiFormat: ClaudeApiFormat;
|
apiFormat: ClaudeApiFormat;
|
||||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||||
|
|
||||||
|
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
|
||||||
|
apiKeyField: ClaudeApiKeyField;
|
||||||
|
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeFormFields({
|
export function ClaudeFormFields({
|
||||||
@@ -102,6 +110,8 @@ export function ClaudeFormFields({
|
|||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
apiFormat,
|
apiFormat,
|
||||||
onApiFormatChange,
|
onApiFormatChange,
|
||||||
|
apiKeyField,
|
||||||
|
onApiKeyFieldChange,
|
||||||
}: ClaudeFormFieldsProps) {
|
}: ClaudeFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -188,8 +198,8 @@ export function ClaudeFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API 格式选择(仅非官方供应商显示) */}
|
{/* API 格式选择(仅非官方、非云服务商显示) */}
|
||||||
{shouldShowModelSelector && (
|
{shouldShowModelSelector && category !== "cloud_provider" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel htmlFor="apiFormat">
|
<FormLabel htmlFor="apiFormat">
|
||||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||||
@@ -219,6 +229,41 @@ export function ClaudeFormFields({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 认证字段选择(仅非官方供应商显示) */}
|
||||||
|
{shouldShowModelSelector && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="apiKeyField">
|
||||||
|
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={apiKeyField}
|
||||||
|
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="apiKeyField" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||||
|
{t("providerForm.authFieldAuthToken", {
|
||||||
|
defaultValue: "Auth Token (默认)",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ANTHROPIC_API_KEY">
|
||||||
|
{t("providerForm.authFieldApiKey", {
|
||||||
|
defaultValue: "API Key",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.authFieldHint", {
|
||||||
|
defaultValue:
|
||||||
|
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 模型选择器 */}
|
{/* 模型选择器 */}
|
||||||
{shouldShowModelSelector && (
|
{shouldShowModelSelector && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates";
|
||||||
|
|
||||||
|
interface ClaudeQuickTogglesProps {
|
||||||
|
/** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */
|
||||||
|
onPatchApplied?: (patch: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStates: Record<ToggleKey, boolean> = {
|
||||||
|
hideAttribution: false,
|
||||||
|
alwaysThinking: false,
|
||||||
|
enableTeammates: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function deriveStates(
|
||||||
|
cfg: Record<string, unknown>,
|
||||||
|
): Record<ToggleKey, boolean> {
|
||||||
|
const env = cfg?.env as Record<string, unknown> | undefined;
|
||||||
|
const attr = cfg?.attribution as Record<string, unknown> | undefined;
|
||||||
|
return {
|
||||||
|
hideAttribution: attr?.commit === "" && attr?.pr === "",
|
||||||
|
alwaysThinking: cfg?.alwaysThinkingEnabled === true,
|
||||||
|
enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */
|
||||||
|
function jsonMergePatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
delete target[key];
|
||||||
|
} else if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
if (
|
||||||
|
typeof target[key] !== "object" ||
|
||||||
|
target[key] === null ||
|
||||||
|
Array.isArray(target[key])
|
||||||
|
) {
|
||||||
|
target[key] = {};
|
||||||
|
}
|
||||||
|
jsonMergePatch(
|
||||||
|
target[key] as Record<string, unknown>,
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
if (Object.keys(target[key] as Record<string, unknown>).length === 0) {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { jsonMergePatch };
|
||||||
|
|
||||||
|
export function ClaudeQuickToggles({
|
||||||
|
onPatchApplied,
|
||||||
|
}: ClaudeQuickTogglesProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [states, setStates] = useState(defaultStates);
|
||||||
|
|
||||||
|
const readLive = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await invoke<Record<string, unknown>>(
|
||||||
|
"read_live_provider_settings",
|
||||||
|
{ app: "claude" },
|
||||||
|
);
|
||||||
|
setStates(deriveStates(cfg));
|
||||||
|
} catch {
|
||||||
|
// Live file missing or unreadable — show all unchecked
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
readLive();
|
||||||
|
}, [readLive]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
async (key: ToggleKey) => {
|
||||||
|
let patch: Record<string, unknown>;
|
||||||
|
if (key === "hideAttribution") {
|
||||||
|
patch = states.hideAttribution
|
||||||
|
? { attribution: null }
|
||||||
|
: { attribution: { commit: "", pr: "" } };
|
||||||
|
} else if (key === "alwaysThinking") {
|
||||||
|
patch = states.alwaysThinking
|
||||||
|
? { alwaysThinkingEnabled: null }
|
||||||
|
: { alwaysThinkingEnabled: true };
|
||||||
|
} else {
|
||||||
|
patch = states.enableTeammates
|
||||||
|
? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } }
|
||||||
|
: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setStates((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("patch_claude_live_settings", { patch });
|
||||||
|
onPatchApplied?.(patch);
|
||||||
|
} catch {
|
||||||
|
// Revert on failure
|
||||||
|
readLive();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[states, readLive, onPatchApplied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["hideAttribution", "claudeConfig.hideAttribution"],
|
||||||
|
["alwaysThinking", "claudeConfig.alwaysThinking"],
|
||||||
|
["enableTeammates", "claudeConfig.enableTeammates"],
|
||||||
|
] as const
|
||||||
|
).map(([key, i18nKey]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
|
||||||
|
{t(i18nKey)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Save, Download, Loader2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface CodexCommonConfigModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodexCommonConfigModal - Common Codex configuration editor modal
|
|
||||||
* Allows editing of common TOML configuration shared across providers
|
|
||||||
*/
|
|
||||||
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
error,
|
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={t("codexConfig.editCommonConfigTitle")}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("codexConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("codexConfig.commonConfigHint")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={`# Common Codex config
|
|
||||||
|
|
||||||
# Add your common TOML configuration here`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={false}
|
|
||||||
language="javascript"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
||||||
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
|
||||||
|
|
||||||
interface CodexConfigEditorProps {
|
interface CodexConfigEditorProps {
|
||||||
authValue: string;
|
authValue: string;
|
||||||
@@ -13,23 +12,9 @@ interface CodexConfigEditorProps {
|
|||||||
|
|
||||||
onAuthBlur?: () => void;
|
onAuthBlur?: () => void;
|
||||||
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
|
|
||||||
commonConfigError: string;
|
|
||||||
|
|
||||||
authError: string;
|
authError: string;
|
||||||
|
|
||||||
configError: string; // config.toml 错误提示
|
configError: string;
|
||||||
|
|
||||||
onExtract?: () => void;
|
|
||||||
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||||
@@ -38,25 +23,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onAuthChange,
|
onAuthChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onAuthBlur,
|
onAuthBlur,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
authError,
|
authError,
|
||||||
configError,
|
configError,
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Auto-open common config modal if there's an error
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Auth JSON Section */}
|
{/* Auth JSON Section */}
|
||||||
@@ -71,23 +40,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
<CodexConfigSection
|
<CodexConfigSection
|
||||||
value={configValue}
|
value={configValue}
|
||||||
onChange={onConfigChange}
|
onChange={onConfigChange}
|
||||||
useCommonConfig={useCommonConfig}
|
|
||||||
onCommonConfigToggle={onCommonConfigToggle}
|
|
||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
commonConfigError={commonConfigError}
|
|
||||||
configError={configError}
|
configError={configError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
|
||||||
<CodexCommonConfigModal
|
|
||||||
isOpen={isCommonConfigModalOpen}
|
|
||||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
error={commonConfigError}
|
|
||||||
onExtract={onExtract}
|
|
||||||
isExtracting={isExtracting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
|||||||
interface CodexConfigSectionProps {
|
interface CodexConfigSectionProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
onEditCommonConfig: () => void;
|
|
||||||
commonConfigError?: string;
|
|
||||||
configError?: string;
|
configError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +87,6 @@ interface CodexConfigSectionProps {
|
|||||||
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
onEditCommonConfig,
|
|
||||||
commonConfigError,
|
|
||||||
configError,
|
configError,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -117,40 +109,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<label
|
||||||
<label
|
htmlFor="codexConfig"
|
||||||
htmlFor="codexConfig"
|
className="block text-sm font-medium text-foreground"
|
||||||
className="block text-sm font-medium text-foreground"
|
>
|
||||||
>
|
{t("codexConfig.configToml")}
|
||||||
{t("codexConfig.configToml")}
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("codexConfig.writeCommonConfig")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditCommonConfig}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("codexConfig.editCommonConfig")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Save, Download, Loader2 } from "lucide-react";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface CommonConfigEditorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
commonConfigError: string;
|
|
||||||
onEditClick: () => void;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
onModalClose: () => void;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommonConfigEditor({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
onEditClick,
|
|
||||||
isModalOpen,
|
|
||||||
onModalClose,
|
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}: CommonConfigEditorProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync
|
|
||||||
// (parent uses form.getValues which doesn't trigger re-renders)
|
|
||||||
const [localValue, setLocalValue] = useState(value);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalValue(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const handleLocalChange = useCallback(
|
|
||||||
(newValue: string) => {
|
|
||||||
setLocalValue(newValue);
|
|
||||||
onChange(newValue);
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleStates = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(localValue);
|
|
||||||
return {
|
|
||||||
hideAttribution:
|
|
||||||
config?.attribution?.commit === "" && config?.attribution?.pr === "",
|
|
||||||
alwaysThinking: config?.alwaysThinkingEnabled === true,
|
|
||||||
teammates:
|
|
||||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
|
|
||||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
hideAttribution: false,
|
|
||||||
alwaysThinking: false,
|
|
||||||
teammates: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [localValue]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
(toggleKey: string, checked: boolean) => {
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(localValue || "{}");
|
|
||||||
|
|
||||||
switch (toggleKey) {
|
|
||||||
case "hideAttribution":
|
|
||||||
if (checked) {
|
|
||||||
config.attribution = { commit: "", pr: "" };
|
|
||||||
} else {
|
|
||||||
delete config.attribution;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "alwaysThinking":
|
|
||||||
if (checked) {
|
|
||||||
config.alwaysThinkingEnabled = true;
|
|
||||||
} else {
|
|
||||||
delete config.alwaysThinkingEnabled;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "teammates":
|
|
||||||
if (!config.env) config.env = {};
|
|
||||||
if (checked) {
|
|
||||||
config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
|
|
||||||
} else {
|
|
||||||
delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
||||||
if (Object.keys(config.env).length === 0) delete config.env;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLocalChange(JSON.stringify(config, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Don't modify if JSON is invalid
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[localValue, handleLocalChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="useCommonConfig"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{t("claudeConfig.writeCommonConfig", {
|
|
||||||
defaultValue: "写入通用配置",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditClick}
|
|
||||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
|
||||||
>
|
|
||||||
{t("claudeConfig.editCommonConfig", {
|
|
||||||
defaultValue: "编辑通用配置",
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{commonConfigError && !isModalOpen && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={toggleStates.hideAttribution}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleToggle("hideAttribution", e.target.checked)
|
|
||||||
}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>{t("claudeConfig.hideAttribution")}</span>
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={toggleStates.alwaysThinking}
|
|
||||||
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>{t("claudeConfig.alwaysThinking")}</span>
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={toggleStates.teammates}
|
|
||||||
onChange={(e) => handleToggle("teammates", e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>{t("claudeConfig.enableTeammates")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<JsonEditor
|
|
||||||
value={localValue}
|
|
||||||
onChange={handleLocalChange}
|
|
||||||
placeholder={`{
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={14}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
title={t("claudeConfig.editCommonConfigTitle", {
|
|
||||||
defaultValue: "编辑通用配置片段",
|
|
||||||
})}
|
|
||||||
onClose={onModalClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("claudeConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onModalClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("claudeConfig.commonConfigHint", {
|
|
||||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<JsonEditor
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
placeholder={`{
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Save, Download, Loader2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface GeminiCommonConfigModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
|
||||||
* Allows editing of common env snippet shared across Gemini providers
|
|
||||||
*/
|
|
||||||
export const GeminiCommonConfigModal: React.FC<
|
|
||||||
GeminiCommonConfigModalProps
|
|
||||||
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={t("geminiConfig.editCommonConfigTitle", {
|
|
||||||
defaultValue: "编辑 Gemini 通用配置片段",
|
|
||||||
})}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("geminiConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("geminiConfig.commonConfigHint", {
|
|
||||||
defaultValue:
|
|
||||||
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={`{
|
|
||||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
||||||
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
|
|
||||||
|
|
||||||
interface GeminiConfigEditorProps {
|
interface GeminiConfigEditorProps {
|
||||||
envValue: string;
|
envValue: string;
|
||||||
@@ -8,15 +7,8 @@ interface GeminiConfigEditorProps {
|
|||||||
onEnvChange: (value: string) => void;
|
onEnvChange: (value: string) => void;
|
||||||
onConfigChange: (value: string) => void;
|
onConfigChange: (value: string) => void;
|
||||||
onEnvBlur?: () => void;
|
onEnvBlur?: () => void;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
commonConfigError: string;
|
|
||||||
envError: string;
|
envError: string;
|
||||||
configError: string;
|
configError: string;
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||||
@@ -25,25 +17,9 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onEnvChange,
|
onEnvChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onEnvBlur,
|
onEnvBlur,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
envError,
|
envError,
|
||||||
configError,
|
configError,
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Auto-open common config modal if there's an error
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Env Section */}
|
{/* Env Section */}
|
||||||
@@ -52,10 +28,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onChange={onEnvChange}
|
onChange={onEnvChange}
|
||||||
onBlur={onEnvBlur}
|
onBlur={onEnvBlur}
|
||||||
error={envError}
|
error={envError}
|
||||||
useCommonConfig={useCommonConfig}
|
|
||||||
onCommonConfigToggle={onCommonConfigToggle}
|
|
||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
commonConfigError={commonConfigError}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Config JSON Section */}
|
{/* Config JSON Section */}
|
||||||
@@ -64,17 +36,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onChange={onConfigChange}
|
onChange={onConfigChange}
|
||||||
configError={configError}
|
configError={configError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
|
||||||
<GeminiCommonConfigModal
|
|
||||||
isOpen={isCommonConfigModalOpen}
|
|
||||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
error={commonConfigError}
|
|
||||||
onExtract={onExtract}
|
|
||||||
isExtracting={isExtracting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ interface GeminiEnvSectionProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
onEditCommonConfig: () => void;
|
|
||||||
commonConfigError?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +17,6 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
error,
|
error,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
onEditCommonConfig,
|
|
||||||
commonConfigError,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
@@ -53,44 +45,12 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<label
|
||||||
<label
|
htmlFor="geminiEnv"
|
||||||
htmlFor="geminiEnv"
|
className="block text-sm font-medium text-foreground"
|
||||||
className="block text-sm font-medium text-foreground"
|
>
|
||||||
>
|
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("geminiConfig.writeCommonConfig", {
|
|
||||||
defaultValue: "写入通用配置",
|
|
||||||
})}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditCommonConfig}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("geminiConfig.editCommonConfig", {
|
|
||||||
defaultValue: "编辑通用配置",
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -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";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useReadOmoLocalFile } from "@/lib/query/omo";
|
import { useReadOmoLocalFile, useReadOmoSlimLocalFile } from "@/lib/query/omo";
|
||||||
import {
|
import {
|
||||||
OMO_BUILTIN_AGENTS,
|
OMO_BUILTIN_AGENTS,
|
||||||
OMO_BUILTIN_CATEGORIES,
|
OMO_BUILTIN_CATEGORIES,
|
||||||
|
OMO_SLIM_BUILTIN_AGENTS,
|
||||||
type OmoAgentDef,
|
type OmoAgentDef,
|
||||||
type OmoCategoryDef,
|
type OmoCategoryDef,
|
||||||
} from "@/types/omo";
|
} from "@/types/omo";
|
||||||
@@ -69,12 +70,13 @@ interface OmoFormFieldsProps {
|
|||||||
>;
|
>;
|
||||||
agents: Record<string, Record<string, unknown>>;
|
agents: Record<string, Record<string, unknown>>;
|
||||||
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
|
onAgentsChange: (agents: Record<string, Record<string, unknown>>) => void;
|
||||||
categories: Record<string, Record<string, unknown>>;
|
categories?: Record<string, Record<string, unknown>>;
|
||||||
onCategoriesChange: (
|
onCategoriesChange?: (
|
||||||
categories: Record<string, Record<string, unknown>>,
|
categories: Record<string, Record<string, unknown>>,
|
||||||
) => void;
|
) => void;
|
||||||
otherFieldsStr: string;
|
otherFieldsStr: string;
|
||||||
onOtherFieldsStrChange: (value: string) => void;
|
onOtherFieldsStrChange: (value: string) => void;
|
||||||
|
isSlim?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CustomModelItem = {
|
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 = 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 BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key));
|
||||||
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
|
const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__";
|
||||||
|
|
||||||
@@ -303,13 +308,21 @@ export function OmoFormFields({
|
|||||||
presetMetaMap: _presetMetaMap = {},
|
presetMetaMap: _presetMetaMap = {},
|
||||||
agents,
|
agents,
|
||||||
onAgentsChange,
|
onAgentsChange,
|
||||||
categories,
|
categories = {},
|
||||||
onCategoriesChange,
|
onCategoriesChange,
|
||||||
otherFieldsStr,
|
otherFieldsStr,
|
||||||
onOtherFieldsStrChange,
|
onOtherFieldsStrChange,
|
||||||
|
isSlim = false,
|
||||||
}: OmoFormFieldsProps) {
|
}: OmoFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
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 [mainAgentsOpen, setMainAgentsOpen] = useState(true);
|
||||||
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
const [subAgentsOpen, setSubAgentsOpen] = useState(true);
|
||||||
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
const [categoriesOpen, setCategoriesOpen] = useState(true);
|
||||||
@@ -329,7 +342,7 @@ export function OmoFormFields({
|
|||||||
>({});
|
>({});
|
||||||
|
|
||||||
const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>
|
const [customAgents, setCustomAgents] = useState<CustomModelItem[]>(() =>
|
||||||
collectCustomModels(agents, BUILTIN_AGENT_KEYS),
|
collectCustomModels(agents, builtinAgentKeys),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(
|
const [customCategories, setCustomCategories] = useState<CustomModelItem[]>(
|
||||||
@@ -337,7 +350,7 @@ export function OmoFormFields({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS));
|
setCustomAgents(collectCustomModels(agents, builtinAgentKeys));
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,17 +362,18 @@ export function OmoFormFields({
|
|||||||
onAgentsChange(
|
onAgentsChange(
|
||||||
mergeCustomModelsIntoStore(
|
mergeCustomModelsIntoStore(
|
||||||
agents,
|
agents,
|
||||||
BUILTIN_AGENT_KEYS,
|
builtinAgentKeys,
|
||||||
customs,
|
customs,
|
||||||
modelVariantsMap,
|
modelVariantsMap,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[agents, onAgentsChange, modelVariantsMap],
|
[agents, onAgentsChange, modelVariantsMap, builtinAgentKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncCustomCategories = useCallback(
|
const syncCustomCategories = useCallback(
|
||||||
(customs: CustomModelItem[]) => {
|
(customs: CustomModelItem[]) => {
|
||||||
|
if (!onCategoriesChange) return;
|
||||||
onCategoriesChange(
|
onCategoriesChange(
|
||||||
mergeCustomModelsIntoStore(
|
mergeCustomModelsIntoStore(
|
||||||
categories,
|
categories,
|
||||||
@@ -708,42 +722,91 @@ export function OmoFormFields({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let filledCount = 0;
|
||||||
|
let alreadySetCount = 0;
|
||||||
|
let unmatchedCount = 0;
|
||||||
|
|
||||||
const updatedAgents = { ...agents };
|
const updatedAgents = { ...agents };
|
||||||
for (const agentDef of OMO_BUILTIN_AGENTS) {
|
for (const agentDef of builtinAgentDefs) {
|
||||||
const recommendedValue = resolveRecommendedModel(agentDef.recommended);
|
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] = {
|
||||||
...updatedAgents[agentDef.key],
|
...updatedAgents[agentDef.key],
|
||||||
model: recommendedValue,
|
model: recommendedValue,
|
||||||
};
|
};
|
||||||
|
filledCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onAgentsChange(updatedAgents);
|
onAgentsChange(updatedAgents);
|
||||||
|
|
||||||
const updatedCategories = { ...categories };
|
if (!isSlim && onCategoriesChange) {
|
||||||
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
const updatedCategories = { ...categories };
|
||||||
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
for (const catDef of OMO_BUILTIN_CATEGORIES) {
|
||||||
if (recommendedValue && !updatedCategories[catDef.key]?.model) {
|
const recommendedValue = resolveRecommendedModel(catDef.recommended);
|
||||||
updatedCategories[catDef.key] = {
|
if (!recommendedValue) {
|
||||||
...updatedCategories[catDef.key],
|
unmatchedCount++;
|
||||||
model: recommendedValue,
|
} 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 configuredAgentCount = Object.keys(agents).length;
|
||||||
const configuredCategoryCount = Object.keys(categories).length;
|
const configuredCategoryCount = isSlim ? 0 : Object.keys(categories).length;
|
||||||
const mainAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "main");
|
const mainAgents = builtinAgentDefs.filter((a) => a.group === "main");
|
||||||
const subAgents = OMO_BUILTIN_AGENTS.filter((a) => a.group === "sub");
|
const subAgents = builtinAgentDefs.filter((a) => a.group === "sub");
|
||||||
|
|
||||||
const readLocalFile = useReadOmoLocalFile();
|
const readLocalFile = useReadOmoLocalFile();
|
||||||
|
const readSlimLocalFile = useReadOmoSlimLocalFile();
|
||||||
const [localFilePath, setLocalFilePath] = useState<string | null>(null);
|
const [localFilePath, setLocalFilePath] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleImportFromLocal = useCallback(async () => {
|
const handleImportFromLocal = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await readLocalFile.mutateAsync();
|
const data = isSlim
|
||||||
|
? await readSlimLocalFile.mutateAsync()
|
||||||
|
: await readLocalFile.mutateAsync();
|
||||||
const importedAgents =
|
const importedAgents =
|
||||||
(data.agents as Record<string, Record<string, unknown>> | undefined) ||
|
(data.agents as Record<string, Record<string, unknown>> | undefined) ||
|
||||||
{};
|
{};
|
||||||
@@ -753,16 +816,20 @@ export function OmoFormFields({
|
|||||||
| undefined) || {};
|
| undefined) || {};
|
||||||
|
|
||||||
onAgentsChange(importedAgents);
|
onAgentsChange(importedAgents);
|
||||||
onCategoriesChange(importedCategories);
|
if (!isSlim && onCategoriesChange) {
|
||||||
|
onCategoriesChange(importedCategories);
|
||||||
|
}
|
||||||
onOtherFieldsStrChange(
|
onOtherFieldsStrChange(
|
||||||
data.otherFields ? JSON.stringify(data.otherFields, null, 2) : "",
|
data.otherFields ? JSON.stringify(data.otherFields, null, 2) : "",
|
||||||
);
|
);
|
||||||
setAgentAdvancedDrafts({});
|
setAgentAdvancedDrafts({});
|
||||||
setCategoryAdvancedDrafts({});
|
setCategoryAdvancedDrafts({});
|
||||||
setCustomAgents(collectCustomModels(importedAgents, BUILTIN_AGENT_KEYS));
|
setCustomAgents(collectCustomModels(importedAgents, builtinAgentKeys));
|
||||||
setCustomCategories(
|
if (!isSlim) {
|
||||||
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
setCustomCategories(
|
||||||
);
|
collectCustomModels(importedCategories, BUILTIN_CATEGORY_KEYS),
|
||||||
|
);
|
||||||
|
}
|
||||||
setLocalFilePath(data.filePath);
|
setLocalFilePath(data.filePath);
|
||||||
toast.success(
|
toast.success(
|
||||||
t("omo.importLocalReplaceSuccess", {
|
t("omo.importLocalReplaceSuccess", {
|
||||||
@@ -792,7 +859,7 @@ export function OmoFormFields({
|
|||||||
) => {
|
) => {
|
||||||
const isAgent = scope === "agent";
|
const isAgent = scope === "agent";
|
||||||
const store = isAgent ? agents : categories;
|
const store = isAgent ? agents : categories;
|
||||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||||
|
|
||||||
@@ -866,7 +933,7 @@ export function OmoFormFields({
|
|||||||
) => {
|
) => {
|
||||||
const isAgent = scope === "agent";
|
const isAgent = scope === "agent";
|
||||||
const store = isAgent ? agents : categories;
|
const store = isAgent ? agents : categories;
|
||||||
const setter = isAgent ? onAgentsChange : onCategoriesChange;
|
const setter = isAgent ? onAgentsChange : onCategoriesChange!;
|
||||||
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
const drafts = isAgent ? agentAdvancedDrafts : categoryAdvancedDrafts;
|
||||||
const expanded = isAgent ? expandedAgents : expandedCategories;
|
const expanded = isAgent ? expandedAgents : expandedCategories;
|
||||||
const customs = isAgent ? customAgents : customCategories;
|
const customs = isAgent ? customAgents : customCategories;
|
||||||
@@ -1153,30 +1220,31 @@ export function OmoFormFields({
|
|||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{renderModelSection({
|
{!isSlim &&
|
||||||
title: t("omo.categories", { defaultValue: "Categories" }),
|
renderModelSection({
|
||||||
isOpen: categoriesOpen,
|
title: t("omo.categories", { defaultValue: "Categories" }),
|
||||||
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
isOpen: categoriesOpen,
|
||||||
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
onToggle: () => setCategoriesOpen(!categoriesOpen),
|
||||||
action: renderCustomAddButton(() => addCustomModel("category")),
|
badge: `${OMO_BUILTIN_CATEGORIES.length + customCategories.length}`,
|
||||||
children: (
|
action: renderCustomAddButton(() => addCustomModel("category")),
|
||||||
<>
|
children: (
|
||||||
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
<>
|
||||||
{customCategories.length > 0 && (
|
{OMO_BUILTIN_CATEGORIES.map(renderCategoryRow)}
|
||||||
<>
|
{customCategories.length > 0 && (
|
||||||
{renderCustomDivider(
|
<>
|
||||||
t("omo.customCategories", {
|
{renderCustomDivider(
|
||||||
defaultValue: "Custom Categories",
|
t("omo.customCategories", {
|
||||||
}),
|
defaultValue: "Custom Categories",
|
||||||
)}
|
}),
|
||||||
{customCategories.map((c, i) =>
|
)}
|
||||||
renderCustomModelRow("category", c, i),
|
{customCategories.map((c, i) =>
|
||||||
)}
|
renderCustomModelRow("category", c, i),
|
||||||
</>
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
)}
|
||||||
),
|
</>
|
||||||
})}
|
),
|
||||||
|
})}
|
||||||
|
|
||||||
{renderModelSection({
|
{renderModelSection({
|
||||||
title: t("omo.otherFieldsJson", {
|
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 {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FlaskConical,
|
// FlaskConical, // Hidden: stream check feature disabled
|
||||||
Globe,
|
Globe,
|
||||||
Coins,
|
Coins,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -87,15 +87,16 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderAdvancedConfig({
|
export function ProviderAdvancedConfig({
|
||||||
testConfig,
|
testConfig: _testConfig, // Hidden: stream check feature disabled
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
pricingConfig,
|
pricingConfig,
|
||||||
onTestConfigChange,
|
onTestConfigChange: _onTestConfigChange, // Hidden: stream check feature disabled
|
||||||
onProxyConfigChange,
|
onProxyConfigChange,
|
||||||
onPricingConfigChange,
|
onPricingConfigChange,
|
||||||
}: ProviderAdvancedConfigProps) {
|
}: ProviderAdvancedConfigProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
// Hidden: stream check feature disabled
|
||||||
|
// const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||||
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||||
proxyConfig.enabled,
|
proxyConfig.enabled,
|
||||||
);
|
);
|
||||||
@@ -110,10 +111,10 @@ export function ProviderAdvancedConfig({
|
|||||||
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
|
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
|
||||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||||
|
|
||||||
// 同步外部 testConfig.enabled 变化到展开状态
|
// Hidden: stream check feature disabled
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
setIsTestConfigOpen(testConfig.enabled);
|
// setIsTestConfigOpen(testConfig.enabled);
|
||||||
}, [testConfig.enabled]);
|
// }, [testConfig.enabled]);
|
||||||
|
|
||||||
// 同步外部 proxyConfig.enabled 变化到展开状态
|
// 同步外部 proxyConfig.enabled 变化到展开状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,7 +168,7 @@ export function ProviderAdvancedConfig({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -343,6 +344,7 @@ export function ProviderAdvancedConfig({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
*/}
|
||||||
|
|
||||||
{/* 代理配置 */}
|
{/* 代理配置 */}
|
||||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
<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";
|
||||||
@@ -6,9 +6,10 @@ export { useCodexConfigState } from "./useCodexConfigState";
|
|||||||
export { useApiKeyLink } from "./useApiKeyLink";
|
export { useApiKeyLink } from "./useApiKeyLink";
|
||||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||||
export { useTemplateValues } from "./useTemplateValues";
|
export { useTemplateValues } from "./useTemplateValues";
|
||||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
|
||||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
|
||||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
export { useOmoModelSource } from "./useOmoModelSource";
|
||||||
|
export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||||
|
export { useOmoDraftState } from "./useOmoDraftState";
|
||||||
|
export { useOpenclawFormState } from "./useOpenclawFormState";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UseApiKeyStateProps {
|
|||||||
selectedPresetId: string | null;
|
selectedPresetId: string | null;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
appType?: string;
|
appType?: string;
|
||||||
|
apiKeyField?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export function useApiKeyState({
|
|||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
appType,
|
appType,
|
||||||
|
apiKeyField,
|
||||||
}: UseApiKeyStateProps) {
|
}: UseApiKeyStateProps) {
|
||||||
const [apiKey, setApiKey] = useState(() => {
|
const [apiKey, setApiKey] = useState(() => {
|
||||||
if (initialConfig) {
|
if (initialConfig) {
|
||||||
@@ -58,7 +60,7 @@ export function useApiKeyState({
|
|||||||
initialConfig || "{}",
|
initialConfig || "{}",
|
||||||
key.trim(),
|
key.trim(),
|
||||||
{
|
{
|
||||||
// 最佳实践:仅在“新增模式”且“非官方类别”时补齐缺失字段
|
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
||||||
// - 新增模式:selectedPresetId !== null
|
// - 新增模式:selectedPresetId !== null
|
||||||
// - 非官方类别:category !== undefined && category !== "official"
|
// - 非官方类别:category !== undefined && category !== "official"
|
||||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||||
@@ -68,12 +70,20 @@ export function useApiKeyState({
|
|||||||
category !== undefined &&
|
category !== undefined &&
|
||||||
category !== "official",
|
category !== "official",
|
||||||
appType,
|
appType,
|
||||||
|
apiKeyField,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onConfigChange(configString);
|
onConfigChange(configString);
|
||||||
},
|
},
|
||||||
[initialConfig, selectedPresetId, category, appType, onConfigChange],
|
[
|
||||||
|
initialConfig,
|
||||||
|
selectedPresetId,
|
||||||
|
category,
|
||||||
|
appType,
|
||||||
|
apiKeyField,
|
||||||
|
onConfigChange,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showApiKey = useCallback(
|
const showApiKey = useCallback(
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
updateTomlCommonConfigSnippet,
|
|
||||||
hasTomlCommonConfigSnippet,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
|
||||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
|
||||||
# Add your common TOML configuration here`;
|
|
||||||
|
|
||||||
interface UseCodexCommonConfigProps {
|
|
||||||
codexConfig: string;
|
|
||||||
onConfigChange: (config: string) => void;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Codex 通用配置片段 (TOML 格式)
|
|
||||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
|
||||||
*/
|
|
||||||
export function useCodexCommonConfig({
|
|
||||||
codexConfig,
|
|
||||||
onConfigChange,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
}: UseCodexCommonConfigProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
||||||
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
|
|
||||||
);
|
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId]);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("codex");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("codex", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载 Codex 通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData?.settingsConfig && !isLoading) {
|
|
||||||
const config =
|
|
||||||
typeof initialData.settingsConfig.config === "string"
|
|
||||||
? initialData.settingsConfig.config
|
|
||||||
: "";
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}
|
|
||||||
}, [initialData, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
|
||||||
hasInitializedNewMode.current = true;
|
|
||||||
|
|
||||||
// 检查 TOML 片段是否有实质内容(不只是注释和空行)
|
|
||||||
const lines = commonConfigSnippet.split("\n");
|
|
||||||
const hasContent = lines.some((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
return trimmed && !trimmed.startsWith("#");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasContent) {
|
|
||||||
setUseCommonConfig(true);
|
|
||||||
// 合并通用配置到当前配置
|
|
||||||
const { updatedConfig, error } = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!error) {
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
initialData,
|
|
||||||
commonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
codexConfig,
|
|
||||||
onConfigChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const { updatedConfig, error: snippetError } =
|
|
||||||
updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
checked,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snippetError) {
|
|
||||||
setCommonConfigError(snippetError);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
// 标记正在通过通用配置更新
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[codexConfig, commonConfigSnippet, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json
|
|
||||||
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 若当前启用通用配置,需要替换为最新片段
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const removeResult = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (removeResult.error) {
|
|
||||||
setCommonConfigError(removeResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addResult = updateTomlCommonConfigSnippet(
|
|
||||||
removeResult.updatedConfig,
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addResult.error) {
|
|
||||||
setCommonConfigError(addResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记正在通过通用配置更新,避免触发状态检查
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(addResult.updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}, [codexConfig, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
|
||||||
settingsConfig: JSON.stringify({
|
|
||||||
config: codexConfig ?? "",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extracted || !extracted.trim()) {
|
|
||||||
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新片段状态
|
|
||||||
setCommonConfigSnippetState(extracted);
|
|
||||||
|
|
||||||
// 保存到后端
|
|
||||||
await configApi.setCommonConfigSnippet("codex", extracted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("提取 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.extractFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsExtracting(false);
|
|
||||||
}
|
|
||||||
}, [codexConfig, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
updateCommonConfigSnippet,
|
|
||||||
hasCommonConfigSnippet,
|
|
||||||
validateJsonConfig,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
|
|
||||||
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
|
||||||
"includeCoAuthoredBy": false
|
|
||||||
}`;
|
|
||||||
|
|
||||||
interface UseCommonConfigSnippetProps {
|
|
||||||
settingsConfig: string;
|
|
||||||
onConfigChange: (config: string) => void;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Claude 通用配置片段
|
|
||||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
|
||||||
*/
|
|
||||||
export function useCommonConfigSnippet({
|
|
||||||
settingsConfig,
|
|
||||||
onConfigChange,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
enabled = true,
|
|
||||||
}: UseCommonConfigSnippetProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
||||||
DEFAULT_COMMON_CONFIG_SNIPPET,
|
|
||||||
);
|
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId, enabled]);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("claude");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("claude", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [enabled]);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (initialData && !isLoading) {
|
|
||||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
|
||||||
configString,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}
|
|
||||||
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
|
||||||
hasInitializedNewMode.current = true;
|
|
||||||
|
|
||||||
// 检查片段是否有实质内容
|
|
||||||
try {
|
|
||||||
const snippetObj = JSON.parse(commonConfigSnippet);
|
|
||||||
const hasContent = Object.keys(snippetObj).length > 0;
|
|
||||||
if (hasContent) {
|
|
||||||
setUseCommonConfig(true);
|
|
||||||
// 合并通用配置到当前配置
|
|
||||||
const { updatedConfig, error } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
if (!error) {
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
enabled,
|
|
||||||
initialData,
|
|
||||||
commonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
settingsConfig,
|
|
||||||
onConfigChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
checked,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snippetError) {
|
|
||||||
setCommonConfigError(snippetError);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
// 标记正在通过通用配置更新
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[settingsConfig, commonConfigSnippet, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
|
|
||||||
console.error("保存通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const { updatedConfig } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证JSON格式
|
|
||||||
const validationError = validateJsonConfig(value, "通用配置片段");
|
|
||||||
if (validationError) {
|
|
||||||
setCommonConfigError(validationError);
|
|
||||||
} else {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json
|
|
||||||
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
|
|
||||||
console.error("保存通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
|
||||||
if (useCommonConfig && !validationError) {
|
|
||||||
const removeResult = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (removeResult.error) {
|
|
||||||
setCommonConfigError(removeResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addResult = updateCommonConfigSnippet(
|
|
||||||
removeResult.updatedConfig,
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addResult.error) {
|
|
||||||
setCommonConfigError(addResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记正在通过通用配置更新,避免触发状态检查
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(addResult.updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("claude", {
|
|
||||||
settingsConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extracted || extracted === "{}") {
|
|
||||||
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 JSON 格式
|
|
||||||
const validationError = validateJsonConfig(extracted, "提取的配置");
|
|
||||||
if (validationError) {
|
|
||||||
setCommonConfigError(validationError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新片段状态
|
|
||||||
setCommonConfigSnippetState(extracted);
|
|
||||||
|
|
||||||
// 保存到后端
|
|
||||||
await configApi.setCommonConfigSnippet("claude", extracted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("提取通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.extractFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsExtracting(false);
|
|
||||||
}
|
|
||||||
}, [settingsConfig, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
|
||||||
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
|
||||||
|
|
||||||
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
|
||||||
"GOOGLE_GEMINI_BASE_URL",
|
|
||||||
"GEMINI_API_KEY",
|
|
||||||
] as const;
|
|
||||||
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
|
||||||
|
|
||||||
interface UseGeminiCommonConfigProps {
|
|
||||||
envValue: string;
|
|
||||||
onEnvChange: (env: string) => void;
|
|
||||||
envStringToObj: (envString: string) => Record<string, string>;
|
|
||||||
envObjToString: (envObj: Record<string, unknown>) => string;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
Object.prototype.toString.call(value) === "[object Object]"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Gemini 通用配置片段 (JSON 格式)
|
|
||||||
* 写入 Gemini 的 .env,但会排除以下敏感字段:
|
|
||||||
* - GOOGLE_GEMINI_BASE_URL
|
|
||||||
* - GEMINI_API_KEY
|
|
||||||
*/
|
|
||||||
export function useGeminiCommonConfig({
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
envStringToObj,
|
|
||||||
envObjToString,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
}: UseGeminiCommonConfigProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
||||||
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
|
|
||||||
);
|
|
||||||
const [commonConfigError, setCommonConfigError] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isExtracting, setIsExtracting] = useState(false);
|
|
||||||
|
|
||||||
// 用于跟踪是否正在通过通用配置更新
|
|
||||||
const isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId]);
|
|
||||||
|
|
||||||
const parseSnippetEnv = useCallback(
|
|
||||||
(
|
|
||||||
snippetString: string,
|
|
||||||
): { env: Record<string, string>; error?: string } => {
|
|
||||||
const trimmed = snippetString.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return { env: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(trimmed);
|
|
||||||
} catch {
|
|
||||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPlainObject(parsed)) {
|
|
||||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = Object.keys(parsed);
|
|
||||||
const forbiddenKeys = keys.filter((key) =>
|
|
||||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
|
||||||
);
|
|
||||||
if (forbiddenKeys.length > 0) {
|
|
||||||
return {
|
|
||||||
env: {},
|
|
||||||
error: t("geminiConfig.commonConfigInvalidKeys", {
|
|
||||||
keys: forbiddenKeys.join(", "),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return {
|
|
||||||
env: {},
|
|
||||||
error: t("geminiConfig.commonConfigInvalidValues"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!normalized) continue;
|
|
||||||
env[key] = normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { env };
|
|
||||||
},
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasEnvCommonConfigSnippet = useCallback(
|
|
||||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
const entries = Object.entries(snippetEnv);
|
|
||||||
if (entries.length === 0) return false;
|
|
||||||
return entries.every(([key, value]) => envObj[key] === value);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applySnippetToEnv = useCallback(
|
|
||||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
const updated = { ...envObj };
|
|
||||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
updated[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeSnippetFromEnv = useCallback(
|
|
||||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
const updated = { ...envObj };
|
|
||||||
for (const [key, value] of Object.entries(snippetEnv)) {
|
|
||||||
if (typeof value === "string" && updated[key] === value) {
|
|
||||||
delete updated[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("gemini");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
const parsed = parseSnippetEnv(legacySnippet);
|
|
||||||
if (parsed.error) {
|
|
||||||
console.warn(
|
|
||||||
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载 Gemini 通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [parseSnippetEnv]);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData?.settingsConfig && !isLoading) {
|
|
||||||
try {
|
|
||||||
const env =
|
|
||||||
isPlainObject(initialData.settingsConfig.env) &&
|
|
||||||
Object.keys(initialData.settingsConfig.env).length > 0
|
|
||||||
? (initialData.settingsConfig.env as Record<string, string>)
|
|
||||||
: {};
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) return;
|
|
||||||
const hasCommon = hasEnvCommonConfigSnippet(
|
|
||||||
env,
|
|
||||||
parsed.env as Record<string, string>,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
} catch {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
commonConfigSnippet,
|
|
||||||
hasEnvCommonConfigSnippet,
|
|
||||||
initialData,
|
|
||||||
isLoading,
|
|
||||||
parseSnippetEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
|
||||||
hasInitializedNewMode.current = true;
|
|
||||||
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) return;
|
|
||||||
const hasContent = Object.keys(parsed.env).length > 0;
|
|
||||||
if (!hasContent) return;
|
|
||||||
|
|
||||||
setUseCommonConfig(true);
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
const merged = applySnippetToEnv(currentEnv, parsed.env);
|
|
||||||
const nextEnvString = envObjToString(merged);
|
|
||||||
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onEnvChange(nextEnvString);
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
initialData,
|
|
||||||
isLoading,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envValue,
|
|
||||||
envStringToObj,
|
|
||||||
envObjToString,
|
|
||||||
applySnippetToEnv,
|
|
||||||
onEnvChange,
|
|
||||||
parseSnippetEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) {
|
|
||||||
setCommonConfigError(parsed.error);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Object.keys(parsed.env).length === 0) {
|
|
||||||
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
const updatedEnvObj = checked
|
|
||||||
? applySnippetToEnv(currentEnv, parsed.env)
|
|
||||||
: removeSnippetFromEnv(currentEnv, parsed.env);
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onEnvChange(envObjToString(updatedEnvObj));
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
applySnippetToEnv,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envObjToString,
|
|
||||||
envStringToObj,
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
parseSnippetEnv,
|
|
||||||
removeSnippetFromEnv,
|
|
||||||
t,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
|
|
||||||
console.error("保存 Gemini 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const parsed = parseSnippetEnv(previousSnippet);
|
|
||||||
if (!parsed.error && Object.keys(parsed.env).length > 0) {
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
|
|
||||||
onEnvChange(envObjToString(updatedEnv));
|
|
||||||
}
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验 JSON 格式
|
|
||||||
const parsed = parseSnippetEnv(value);
|
|
||||||
if (parsed.error) {
|
|
||||||
setCommonConfigError(parsed.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
configApi.setCommonConfigSnippet("gemini", value).catch((error) => {
|
|
||||||
console.error("保存 Gemini 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 若当前启用通用配置,需要替换为最新片段
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const prevParsed = parseSnippetEnv(previousSnippet);
|
|
||||||
const prevEnv = prevParsed.error ? {} : prevParsed.env;
|
|
||||||
const nextEnv = parsed.env;
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
|
|
||||||
const withoutOld =
|
|
||||||
Object.keys(prevEnv).length > 0
|
|
||||||
? removeSnippetFromEnv(currentEnv, prevEnv)
|
|
||||||
: currentEnv;
|
|
||||||
const withNew =
|
|
||||||
Object.keys(nextEnv).length > 0
|
|
||||||
? applySnippetToEnv(withoutOld, nextEnv)
|
|
||||||
: withoutOld;
|
|
||||||
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onEnvChange(envObjToString(withNew));
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
applySnippetToEnv,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envObjToString,
|
|
||||||
envStringToObj,
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
parseSnippetEnv,
|
|
||||||
removeSnippetFromEnv,
|
|
||||||
t,
|
|
||||||
useCommonConfig,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) return;
|
|
||||||
const envObj = envStringToObj(envValue);
|
|
||||||
setUseCommonConfig(
|
|
||||||
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
envValue,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envStringToObj,
|
|
||||||
hasEnvCommonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
parseSnippetEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
|
|
||||||
settingsConfig: JSON.stringify({
|
|
||||||
env: envStringToObj(envValue),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extracted || extracted === "{}") {
|
|
||||||
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 JSON 格式
|
|
||||||
const parsed = parseSnippetEnv(extracted);
|
|
||||||
if (parsed.error) {
|
|
||||||
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新片段状态
|
|
||||||
setCommonConfigSnippetState(extracted);
|
|
||||||
|
|
||||||
// 保存到后端
|
|
||||||
await configApi.setCommonConfigSnippet("gemini", extracted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("提取 Gemini 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("geminiConfig.extractFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsExtracting(false);
|
|
||||||
}
|
|
||||||
}, [envStringToObj, envValue, parseSnippetEnv, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -408,9 +408,6 @@ export function ProxyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 空白区域避免冲突 */}
|
|
||||||
<div className="h-4"></div>
|
|
||||||
|
|
||||||
{/* 基础设置 - 监听地址/端口 */}
|
{/* 基础设置 - 监听地址/端口 */}
|
||||||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -46,9 +46,15 @@ import {
|
|||||||
getSessionKey,
|
getSessionKey,
|
||||||
} from "./utils";
|
} 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 { t } = useTranslation();
|
||||||
const { data, isLoading, refetch } = useSessionsQuery();
|
const { data, isLoading, refetch } = useSessionsQuery();
|
||||||
const sessions = data ?? [];
|
const sessions = data ?? [];
|
||||||
@@ -63,7 +69,9 @@ export function SessionManagerPage() {
|
|||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
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);
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
// 使用 FlexSearch 全文搜索
|
// 使用 FlexSearch 全文搜索
|
||||||
@@ -265,9 +273,7 @@ export function SessionManagerPage() {
|
|||||||
icon={
|
icon={
|
||||||
providerFilter === "all"
|
providerFilter === "all"
|
||||||
? "apps"
|
? "apps"
|
||||||
: providerFilter === "codex"
|
: getProviderIconName(providerFilter)
|
||||||
? "openai"
|
|
||||||
: "claude"
|
|
||||||
}
|
}
|
||||||
name={providerFilter}
|
name={providerFilter}
|
||||||
size={14}
|
size={14}
|
||||||
@@ -309,6 +315,36 @@ export function SessionManagerPage() {
|
|||||||
<span>Claude Code</span>
|
<span>Claude Code</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const getProviderLabel = (
|
|||||||
export const getProviderIconName = (providerId: string) => {
|
export const getProviderIconName = (providerId: string) => {
|
||||||
if (providerId === "codex") return "openai";
|
if (providerId === "codex") return "openai";
|
||||||
if (providerId === "claude") return "claude";
|
if (providerId === "claude") return "claude";
|
||||||
|
if (providerId === "opencode") return "opencode";
|
||||||
|
if (providerId === "openclaw") return "openclaw";
|
||||||
return providerId;
|
return providerId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
@@ -30,8 +37,48 @@ interface ToolVersion {
|
|||||||
version: string | null;
|
version: string | null;
|
||||||
latest_version: string | null;
|
latest_version: string | null;
|
||||||
error: 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)
|
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code (Native install - recommended)
|
||||||
curl -fsSL https://claude.ai/install.sh | bash
|
curl -fsSL https://claude.ai/install.sh | bash
|
||||||
# Codex
|
# Codex
|
||||||
@@ -59,30 +106,104 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
isChecking,
|
isChecking,
|
||||||
} = useUpdate();
|
} = 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);
|
setIsLoadingTools(true);
|
||||||
try {
|
try {
|
||||||
const tools = await settingsApi.getToolVersions();
|
// Respect current UI overrides (shell / flag) when doing a full refresh.
|
||||||
setToolVersions(tools);
|
const versions = await settingsApi.getToolVersions(
|
||||||
|
[...TOOL_NAMES],
|
||||||
|
wslShellByTool,
|
||||||
|
);
|
||||||
|
setToolVersions(versions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AboutSection] Failed to load tool versions", error);
|
console.error("[AboutSection] Failed to load tool versions", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingTools(false);
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [appVersion, tools] = await Promise.all([
|
const [appVersion] = await Promise.all([
|
||||||
getVersion(),
|
getVersion(),
|
||||||
settingsApi.getToolVersions(),
|
loadAllToolVersions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
setVersion(appVersion);
|
setVersion(appVersion);
|
||||||
setToolVersions(tools);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AboutSection] Failed to load info", error);
|
console.error("[AboutSection] Failed to load info", error);
|
||||||
@@ -92,7 +213,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (active) {
|
if (active) {
|
||||||
setIsLoadingVersion(false);
|
setIsLoadingVersion(false);
|
||||||
setIsLoadingTools(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -101,6 +221,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
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) ...
|
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
|
||||||
@@ -306,7 +430,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 gap-1.5 text-xs"
|
className="h-7 gap-1.5 text-xs"
|
||||||
onClick={loadToolVersions}
|
onClick={() => loadAllToolVersions()}
|
||||||
disabled={isLoadingTools}
|
disabled={isLoadingTools}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -317,8 +441,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 px-1">
|
||||||
{["claude", "codex", "gemini", "opencode"].map((toolName, index) => {
|
{TOOL_NAMES.map((toolName, index) => {
|
||||||
const tool = toolVersions.find((item) => item.name === toolName);
|
const tool = toolVersions.find((item) => item.name === toolName);
|
||||||
// Special case for OpenCode (capital C), others use capitalize
|
// Special case for OpenCode (capital C), others use capitalize
|
||||||
const displayName =
|
const displayName =
|
||||||
@@ -340,19 +465,74 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">{displayName}</span>
|
<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>
|
</div>
|
||||||
{isLoadingTools ? (
|
{isLoadingTools || loadingTools[toolName] ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
) : tool?.version ? (
|
) : tool?.version ? (
|
||||||
<div className="flex items-center gap-1.5">
|
tool.latest_version &&
|
||||||
{tool.latest_version &&
|
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">
|
||||||
<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}
|
||||||
{tool.latest_version}
|
</span>
|
||||||
</span>
|
) : (
|
||||||
)}
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
</div>
|
)
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,299 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { Server, Activity, ChevronDown, Zap, Globe } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ProxyPanel } from "@/components/proxy";
|
||||||
|
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
||||||
|
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 [&[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-4 border-t border-border/50">
|
||||||
|
<ToggleRow
|
||||||
|
icon={<Zap className="h-4 w-4 text-green-500" />}
|
||||||
|
title={t("settings.advanced.proxy.enableFeature")}
|
||||||
|
description={t(
|
||||||
|
"settings.advanced.proxy.enableFeatureDescription",
|
||||||
|
)}
|
||||||
|
checked={settings?.enableLocalProxy ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onAutoSave({ enableLocalProxy: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ProxyPanel />
|
||||||
|
</div>
|
||||||
|
</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,
|
Loader2,
|
||||||
Save,
|
Save,
|
||||||
FolderSearch,
|
FolderSearch,
|
||||||
Activity,
|
|
||||||
Coins,
|
|
||||||
Database,
|
Database,
|
||||||
Server,
|
Cloud,
|
||||||
ChevronDown,
|
|
||||||
Zap,
|
|
||||||
Globe,
|
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
HardDriveDownload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -39,24 +34,18 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
|
|||||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||||
|
import { BackupListSection } from "@/components/settings/BackupListSection";
|
||||||
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
|
||||||
import { AboutSection } from "@/components/settings/AboutSection";
|
import { AboutSection } from "@/components/settings/AboutSection";
|
||||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
|
||||||
import { ProxyPanel } from "@/components/proxy";
|
// Hidden: stream check feature disabled
|
||||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
// import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
|
||||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
|
||||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
|
||||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||||
import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel";
|
|
||||||
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
import { useImportExport } from "@/hooks/useImportExport";
|
import { useImportExport } from "@/hooks/useImportExport";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
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 {
|
interface SettingsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -188,25 +177,6 @@ export function SettingsPage({
|
|||||||
|
|
||||||
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden px-6">
|
<div className="flex flex-col h-full overflow-hidden px-6">
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
@@ -219,10 +189,11 @@ export function SettingsPage({
|
|||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
className="flex flex-col h-full"
|
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">
|
<TabsTrigger value="general">
|
||||||
{t("settings.tabGeneral")}
|
{t("settings.tabGeneral")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
|
||||||
<TabsTrigger value="advanced">
|
<TabsTrigger value="advanced">
|
||||||
{t("settings.tabAdvanced")}
|
{t("settings.tabAdvanced")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -269,6 +240,15 @@ export function SettingsPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
|
||||||
{settings ? (
|
{settings ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -317,256 +297,6 @@ export function SettingsPage({
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</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
|
<AccordionItem
|
||||||
value="data"
|
value="data"
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
@@ -596,11 +326,61 @@ export function SettingsPage({
|
|||||||
onExport={exportConfig}
|
onExport={exportConfig}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
/>
|
/>
|
||||||
<div className="pt-6">
|
</AccordionContent>
|
||||||
<WebdavSyncSection
|
</AccordionItem>
|
||||||
config={settings?.webdavSync}
|
|
||||||
/>
|
<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>
|
</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>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||||
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
|
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
|
||||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
interface WindowSettingsProps {
|
interface WindowSettingsProps {
|
||||||
settings: SettingsFormState;
|
settings: SettingsFormState;
|
||||||
@@ -27,13 +28,25 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
|||||||
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
|
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleRow
|
<AnimatePresence initial={false}>
|
||||||
icon={<EyeOff className="h-4 w-4 text-green-500" />}
|
{settings.launchOnStartup && (
|
||||||
title={t("settings.silentStartup")}
|
<motion.div
|
||||||
description={t("settings.silentStartupDescription")}
|
key="silent-startup"
|
||||||
checked={!!settings.silentStartup}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
onCheckedChange={(value) => onChange({ silentStartup: value })}
|
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
|
<ToggleRow
|
||||||
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
|
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 { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { settingsApi, skillsApi } from "@/lib/api";
|
import { settingsApi, skillsApi } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
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 { AppCountBar } from "@/components/common/AppCountBar";
|
||||||
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
import { AppToggleGroup } from "@/components/common/AppToggleGroup";
|
||||||
import { ListItemRow } from "@/components/common/ListItemRow";
|
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 };
|
const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
|
||||||
if (!skills) return counts;
|
if (!skills) return counts;
|
||||||
skills.forEach((skill) => {
|
skills.forEach((skill) => {
|
||||||
for (const app of APP_IDS) {
|
for (const app of MCP_SKILLS_APP_IDS) {
|
||||||
if (skill.apps[app]) counts[app]++;
|
if (skill.apps[app]) counts[app]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -159,6 +159,7 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
<AppCountBar
|
<AppCountBar
|
||||||
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
|
||||||
counts={enabledCounts}
|
counts={enabledCounts}
|
||||||
|
appIds={MCP_SKILLS_APP_IDS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||||
@@ -282,6 +283,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
|
|||||||
<AppToggleGroup
|
<AppToggleGroup
|
||||||
apps={skill.apps}
|
apps={skill.apps}
|
||||||
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
|
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">
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
|||||||
@@ -8,10 +8,23 @@ import { ProviderStatsTable } from "./ProviderStatsTable";
|
|||||||
import { ModelStatsTable } from "./ModelStatsTable";
|
import { ModelStatsTable } from "./ModelStatsTable";
|
||||||
import type { TimeRange } from "@/types/usage";
|
import type { TimeRange } from "@/types/usage";
|
||||||
import { motion } from "framer-motion";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { usageKeys } from "@/lib/query/usage";
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||||
|
|
||||||
export function UsageDashboard() {
|
export function UsageDashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -129,6 +142,31 @@ export function UsageDashboard() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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>
|
</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,
|
Power,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Circle,
|
Circle,
|
||||||
|
Calendar,
|
||||||
|
ChevronRight,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { workspaceApi } from "@/lib/api/workspace";
|
import { workspaceApi } from "@/lib/api/workspace";
|
||||||
import WorkspaceFileEditor from "./WorkspaceFileEditor";
|
import WorkspaceFileEditor from "./WorkspaceFileEditor";
|
||||||
|
import DailyMemoryPanel from "./DailyMemoryPanel";
|
||||||
|
|
||||||
interface WorkspaceFile {
|
interface WorkspaceFile {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -51,6 +55,7 @@ const WorkspaceFilesPanel: React.FC = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||||
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
|
const [fileExists, setFileExists] = useState<Record<string, boolean>>({});
|
||||||
|
const [showDailyMemory, setShowDailyMemory] = useState(false);
|
||||||
|
|
||||||
const checkFileExistence = async () => {
|
const checkFileExistence = async () => {
|
||||||
const results: Record<string, boolean> = {};
|
const results: Record<string, boolean> = {};
|
||||||
@@ -79,8 +84,13 @@ const WorkspaceFilesPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 pt-4 pb-8">
|
<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/
|
~/.openclaw/workspace/
|
||||||
|
<FolderOpen className="w-3.5 h-3.5" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
@@ -115,6 +125,27 @@ const WorkspaceFilesPanel: React.FC = () => {
|
|||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<WorkspaceFileEditor
|
<WorkspaceFileEditor
|
||||||
@@ -122,6 +153,11 @@ const WorkspaceFilesPanel: React.FC = () => {
|
|||||||
isOpen={!!editingFile}
|
isOpen={!!editingFile}
|
||||||
onClose={handleEditorClose}
|
onClose={handleEditorClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DailyMemoryPanel
|
||||||
|
isOpen={showDailyMemory}
|
||||||
|
onClose={() => setShowDailyMemory(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user