diff --git a/.gitignore b/.gitignore index 411f24951..fc211bf72 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ nul flatpak/cc-switch.deb flatpak-build/ flatpak-repo/ +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9a09874..d25292165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,186 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +--- + +## [3.11.0] - 2026-02-26 + +### Feature Release + +This release introduces **OpenClaw** as the fifth supported application, a full **Session Manager** for browsing conversation history across all apps, an independent **Backup Management** panel, **Oh My OpenCode (OMO)** integration, and 50+ other features, fixes, and improvements across 147 commits. + +**Stats**: 147 commits | 274 files changed | +32,179 insertions | -5,467 deletions + +### Added + +#### OpenClaw Support (New Application) + +- **OpenClaw Integration**: Full management support for OpenClaw as the fifth application in CC Switch, including provider switching, configuration panels (Env / Tools / Agents Defaults), Workspace file management (HEARTBEAT / BOOTSTRAP / BOOT), daily memory files, and additive overlay mode +- **OpenClaw Provider Presets**: 13+ built-in provider presets with brand icon and complete i18n (zh/en/ja) +- **OpenClaw Form Fields**: Dedicated provider form with providerKey input, model allowlist auto-registration, and default model button +- **OpenClaw Config Panels**: Env editor, Tools editor, and Agents Defaults editor backed by JSON5 read/write (`openclaw_config.rs`) + +#### Session Manager + +- **Session Manager**: Browse and search conversation history for Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw with table-of-contents navigation and in-session search +- **Session App Filter**: Auto-filter sessions by current app when entering the session page +- **Session Performance**: Parallel directory scanning and head-tail JSONL reading for faster session list loading + +#### Backup Management + +- **Backup Panel**: Independent backup management panel with configurable backup policy (max count, auto-cleanup) and backup rename support +- **Periodic Backup**: Hourly automatic backup timer during runtime +- **Pre-Migration Backup**: Automatic backup before database schema migrations with backfill warning +- **Delete Backup**: Delete individual backup files with confirmation dialog +- **Backup Time Fix**: Use local time instead of UTC for backup file names + +#### Oh My OpenCode (OMO) + +- **OMO Integration**: Full Oh My OpenCode config file management with agent model selection, category configuration, and recommended model fill +- **OMO Slim**: Lightweight oh-my-opencode-slim mode support with OmoVariant parameterization +- **OMO Cross-Exclusion**: Enforce OMO ↔ OMO Slim mutual exclusion at the database level + +#### Workspace + +- **Daily Memory Search**: Full-text search across daily memory files with date-sorted display +- **Clickable Paths**: Directory paths in workspace panels are now clickable; renamed “Today's Note” to “Add Memory” +- **Workspace Files Panel**: Manage bootstrap markdown files for OpenClaw (HEARTBEAT / BOOTSTRAP / BOOT types) + +#### Provider Presets + +- **AWS Bedrock**: Support for AKSK and API Key authentication modes (Claude and OpenCode) +- **SSAI Code**: Partner provider preset across all five apps +- **CrazyRouter**: Partner provider preset with custom icon +- **AICoding**: Partner provider preset with i18n promotion text +- **Bailian**: Renamed from Qwen Coder with new icon; updated domestic model providers to latest versions + +#### Proxy & Network + +- **Thinking Budget Rectifier**: New rectifier for thinking budget parameters with dedicated module (`thinking_budget_rectifier.rs`) +- **WebDAV Auto Sync**: Automatic periodic sync with large file protection mechanism + +#### UI & UX + +- **Theme Animation**: Circular reveal animation when toggling between light and dark themes +- **Claude Quick Toggles**: Quick toggle switches in the Claude config JSON editor for common settings +- **Dynamic Endpoint Hint**: Context-aware hint text in endpoint input based on API format selection +- **AppSwitcher Auto Compact**: Automatically collapse to compact mode based on available width, with smooth transition animation +- **App Transition**: Fade-in/fade-out animation when switching between OpenClaw and other apps +- **Silent Startup Conditional**: Show silent startup option only when launch-on-startup is enabled + +#### Settings & Environment + +- **First-Run Confirmation**: Confirmation dialogs for proxy and usage features on first use +- **Local Proxy Toggle**: `enableLocalProxy` setting to control proxy UI visibility on the home page +- **Environment Check**: More granular local environment detection (installed CLI tool versions, Volta path detection) + +#### Usage & Pricing + +- **Usage Dashboard Enhancement**: Auto-refresh control, robust formatting, and request log table improvements +- **New Model Pricing**: Added pricing data for claude-opus-4-6 and gpt-5.3-codex with incremental data seeding + +### Changed + +#### Architecture + +- **Partial Key-Field Merging (⚠️ Breaking)**: Provider switching now uses partial key-field merging instead of full config overwrite, preserving user's non-provider settings (plugins, MCP, permissions). The "Common Config Snippet" feature has been removed as it is no longer needed. Removes 6 frontend files and ~150 lines of backend dead code (#1098) +- **Manual Import**: Replaced auto-import on startup with manual “Import Current Config” button in empty state, reducing ~47 lines of startup code +- **OMO Variant Parameterization**: Eliminated ~250 lines of OMO/OMO Slim code duplication via `OmoVariant` struct with STANDARD/SLIM constants +- **OMO Common Config Removal**: Removed the two-layer merge system for OMO common config (-1,733 lines across 21 files) + +#### Code Quality + +- **ProviderForm Decomposition**: Extracted ProviderForm.tsx from 2,227 lines to 1,526 lines by splitting into 5 focused modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState) +- **Shared MCP/Skills Components**: Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to eliminate duplication across MCP and Skills panels +- **OpenClaw TanStack Query Migration**: Migrated Env, Tools, and AgentsDefaults panels from manual useState/useEffect to centralized TanStack Query hooks + +#### Settings Layout + +- **Proxy Tab**: Split Advanced tab into dedicated Proxy tab (local proxy, failover, rectifiers, global outbound proxy); moved pricing config to Usage dashboard as collapsible accordion. SettingsPage reduced from ~716 to ~426 lines with 5-tab layout: General | Proxy | Advanced | Usage | About +- **Data Section Split**: Split data accordion into Import/Export and Cloud Sync sections for better discoverability + +#### Terminal & Config + +- **Unified Terminal Selection**: Consolidated terminal preference to global settings; added WezTerm support and terminal name mapping (iterm2 → iterm) +- **OpenClaw Agents Panel**: Primary model field set to read-only; detailed model fields (context window, max tokens, reasoning, cost) moved to advanced options +- **Claude Model Update**: Updated Claude model references from 4.5 to 4.6 across all provider presets + ### Fixed -- **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 --- diff --git a/docs/release-note-v3.11.0-en.md b/docs/release-note-v3.11.0-en.md new file mode 100644 index 000000000..4567d81b5 --- /dev/null +++ b/docs/release-note-v3.11.0-en.md @@ -0,0 +1,302 @@ +# CC Switch v3.11.0 + +> OpenClaw Support, Session Manager, Backup Management & 50+ Improvements + +**[中文版 →](release-note-v3.11.0-zh.md) | [日本語版 →](release-note-v3.11.0-ja.md)** + +--- + +## Overview + +CC Switch v3.11.0 is a major update that adds full management support for **OpenClaw** as the fifth application, introduces a new **Session Manager** and **Backup Management** feature. Additionally, **Oh My OpenCode (OMO) integration**, the **partial key-field merging** architecture upgrade for provider switching, **settings page refactoring**, and many other improvements make the overall experience more polished. + +**Release Date**: 2026-02-26 + +**Update Scale**: 147 commits | 274 files changed | +32,179 / -5,467 lines + +--- + +## Highlights + +- **OpenClaw Support**: Fifth managed application with 13 provider presets, Env/Tools/AgentsDefaults config editors, and Workspace file management +- **Session Manager**: Browse conversation history across all five apps with table-of-contents navigation and in-session search +- **Backup Management**: Independent backup panel with configurable policies, periodic backups, and pre-migration auto-backup +- **Oh My OpenCode Integration**: Full OMO config management with OMO Slim lightweight mode support +- **Partial Key-Field Merging (⚠️ Breaking Change)**: Provider switching now only replaces provider-related fields, preserving all other settings; the "Common Config Snippet" feature has been removed +- **Settings Page Refactoring**: 5-tab layout with ~40% code reduction +- **6 New Provider Presets**: AWS Bedrock, SSAI Code, CrazyRouter, AICoding, and more +- **Thinking Budget Rectifier**: Fine-grained thinking budget control +- **Theme Switch Animation**: Circular reveal transition animation +- **WebDAV Auto Sync**: Automatic sync with large file protection + +--- + +## Main Features + +### OpenClaw Support (New Fifth App) + +Full management support for OpenClaw, the fifth managed application following Claude Code, Codex, Gemini CLI, and OpenCode. + +- **Provider Management**: Add, edit, switch, and delete OpenClaw providers with 13 built-in presets +- **Config Editors**: Three dedicated panels for Env (environment variables), Tools, and AgentsDefaults +- **Workspace Panel**: HEARTBEAT/BOOTSTRAP/BOOT file management and daily memory +- **Additive Overlay Mode**: Support config overlay instead of overwrite +- **Default Model Button**: One-click to fill recommended models; auto-register suggested models to allowlist when adding providers +- **Brand & Interaction**: Dedicated brand icon, fade-in/fade-out transition animation when switching apps +- **Deep Link Support**: Import OpenClaw provider configurations via URL +- **Full Internationalization**: Complete Chinese/English/Japanese support + +### Session Manager + +A brand-new session manager to browse and search conversation history. + +- Browse conversation history across Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw (#867, thanks @TinsFox) +- Table-of-contents navigation and in-session search +- Auto-filter by current app when entering the session page +- Parallel directory scanning + head-tail JSONL reading for optimized loading performance + +### Backup Management + +An independent backup management panel for better data safety. + +- Configurable backup policy: maximum backup count and auto-cleanup rules +- Hourly automatic backup timer during runtime +- Auto-backup before database schema migrations with backfill warning +- Support backup rename and deletion (with confirmation dialog) +- Backup filenames use local time for better clarity + +### Oh My OpenCode (OMO) Integration + +Full Oh My OpenCode config file management. + +- Agent model selection, category configuration, and recommended model fill (#972, thanks @yovinchen) +- Improved agent model selection UX with lowercase key fix (#1004, thanks @yovinchen) +- OMO Slim lightweight mode support +- OMO ↔ OMO Slim mutual exclusion (enforced at database level) + +### Workspace + +- Full-text search across daily memory files, sorted by date +- Clickable directory paths for quick file location access + +### Toolbar + +- AppSwitcher auto-collapses to compact mode based on available width +- Smooth transition animation for compact mode toggle + +### Settings + +- First-use confirmation dialogs for proxy and usage features to prevent accidental operations +- New `enableLocalProxy` switch to control proxy UI visibility on home page +- More granular local environment checks: CLI tool version detection (#870, thanks @kv-chiu), Volta path detection (#969, thanks @myjustify) + +### Provider Presets + +- **AWS Bedrock**: Support for AKSK and API Key authentication modes (#1047, thanks @keithyt06) +- **SSAI Code**: Partner preset across all five apps +- **CrazyRouter**: Partner preset with dedicated icon +- **AICoding**: Partner preset with i18n promotion text +- Updated domestic model provider presets to latest versions +- Renamed Qwen Coder to Bailian (#965, thanks @zhu-jl18) + +### Other New Features + +- **Thinking Budget Rectifier**: Fine-grained thinking budget allocation control (#1005, thanks @yovinchen) +- **WebDAV Auto Sync**: Automatic sync with large file protection (#923, thanks @clx20000410; #1043, thanks @SaladDay) +- **Theme Switch Animation**: Circular reveal transition for a smoother visual experience (#905, thanks @funnytime75) +- **Claude Config Editor Quick Toggles**: Quick toggle switches for common settings (#1012, thanks @JIA-ss) +- **Dynamic Endpoint Hint**: Context-aware hint text based on API format selection (#860, thanks @zhu-jl18) +- **Usage Dashboard Enhancement**: Auto-refresh control and robust formatting (#942, thanks @yovinchen) +- **New Pricing Data**: claude-opus-4-6 and gpt-5.3-codex (#943, thanks @yovinchen) +- **Silent Startup Optimization**: Silent startup option only shown when launch-on-startup is enabled + +--- + +## Architecture Improvements + +### Partial Key-Field Merging (⚠️ Breaking Change) + +Provider switching now uses partial key-field merging instead of full config overwrite (#1098). + +**Before**: Switching providers overwrote the entire `settings_config` to the live config file. This meant that any non-provider settings the user manually added to the live file (plugins, MCP config, permissions, etc.) would be lost on every switch. To work around this, previous versions offered a "Common Config Snippet" feature that let users define shared config to be merged on every switch. + +**After**: Switching providers now only replaces provider-related key-values (API keys, endpoints, models, etc.), leaving all other settings intact. The "Common Config Snippet" feature is therefore no longer needed and has been removed. + +**Impact & Migration**: +- If you **didn't use** Common Config Snippets, this change is fully transparent — switching just works better now +- If you **used** Common Config Snippets to preserve custom settings (MCP config, permissions, etc.), those settings are now automatically preserved during switches — no action needed +- If you used Common Config Snippets for other purposes (e.g., injecting extra config on every switch), please manually add those settings to your live config file after upgrading + +This refactoring removed 6 frontend files (3 components + 3 hooks) and ~150 lines of backend dead code. + +### Manual Import Replaces Auto-Import + +Startup no longer auto-imports external configurations. Users now click "Import Current Config" manually, preventing accidental data overwrites. + +### OmoVariant Parameterization + +Eliminated ~250 lines of duplicated code in the OMO module via `OmoVariant` struct parameterization. + +### OMO Common Config Removal + +Removed the two-layer merge system, reducing ~1,733 lines of code and simplifying the architecture. + +### ProviderForm Decomposition + +Reduced ProviderForm component from 2,227 lines to 1,526 lines by extracting 5 independent modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState), significantly improving maintainability. + +### Shared MCP/Skills Components + +Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to reduce duplication across MCP and Skills panels (#897, thanks @PeanutSplash). + +### Settings Page Refactoring + +Refactored settings page to a 5-tab layout (General | Proxy | Advanced | Usage | About), reducing SettingsPage code from ~716 to ~426 lines. + +### Other Improvements + +- Unified terminal selection via global settings with WezTerm support added +- Updated Claude model references from 4.5 to 4.6 + +--- + +## Bug Fixes + +### Critical Fixes + +- **Windows Home Dir Regression**: Restored default home directory resolution to prevent providers/settings "disappearing" when `HOME` env var differs from the real user profile directory in Git/MSYS environments +- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent blank screen on startup (#986, thanks @ThendCN) +- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to `/v1/chat/completions` endpoints, fixing request failures for Nvidia and other `apiFormat="openai_chat"` providers (#1052, thanks @jnorthrup) +- **Health Check Auth**: Health check now respects provider's `auth_mode` setting, preventing failures for proxy services that only support Bearer authentication (#824, thanks @Jassy930) + +### Provider Preset Fixes + +- Fixed OpenClaw `/v1` prefix causing double path (/v1/v1/messages) +- Corrected Opus pricing ($15/$75 → $5/$25) and upgraded to 4.6 +- Unified AIGoCode URL to `https://api.aigocode.com` across all apps +- Removed outdated partner status from Zhipu GLM presets +- Restored API Key input visibility when creating new Claude providers +- Hide quick toggles for non-active providers, show context-aware JSON editor hints + +### OMO Fixes + +- Added missing omo-slim category checks across add/form/mutation paths +- Fixed OMO Slim query cache invalidation after provider mutations +- Synced OMO agent/category recommended models with upstream sources +- Added toast feedback for "Fill Recommended" button silent failures +- Removed last-provider deletion restriction for OMO/OMO Slim +- Reject saving OpenCode providers without configured models (#932, thanks @yovinchen) + +### OpenClaw Fixes + +- Fixed 25 missing i18n keys, replaced key={index} with stable IDs, added deep link additive merge, and other code review issues +- Enhanced EnvPanel robustness (NaN guards, entry key names instead of array indices) +- Merged duplicate i18n keys to restore provider form translations + +### Platform Fixes + +- Windows silent startup window flicker (#901, thanks @funnytime75) +- Title bar dark mode theme following (#903, thanks @funnytime75) +- Windows Skills path separator matching (#868, thanks @stmoonar) +- WSL helper functions conditional compilation + +### UI Fixes + +- Toolbar height clipping causing AppSwitcher to be obscured +- Show update badge instead of green checkmark when newer version available +- Session Manager button only visible for Claude/Codex apps +- Unified SQL import/export card dark mode styling (#1067, thanks @SaladDay) + +### Other Fixes + +- Replaced hardcoded Chinese strings in Session Manager with i18n keys +- Fixed Skill documentation URL branch and path resolution (#977, thanks @yovinchen) +- Added missing OpenCode install.sh installation path detection (#988, thanks @zhu-jl18) +- Fixed Skill ZIP symlink resolution (#1040, thanks @yovinchen) +- Added missing OpenCode checkbox in MCP add/edit form (#1026, thanks @yovinchen) +- Removed auto-import side effect from useProvidersQuery queryFn + +--- + +## Performance + +- Parallel directory scanning + head-tail JSONL reading for session panel, significantly improving session list loading speed +- Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls + +--- + +## Documentation + +- Sponsor updates: SSSAiCode, Crazyrouter, AICoding, Right Code, MiniMax +- Added user manual documentation (#979, thanks @yovinchen) + +--- + +## Notes & Considerations + +- **OpenClaw is a newly supported app**: OpenClaw CLI must be installed first to use related features. +- **⚠️ Common Config Snippet feature has been removed**: Since provider switching now uses partial key-field merging (only replacing API keys, endpoints, models, etc.), user's other settings are automatically preserved, making Common Config Snippets unnecessary. See the "Architecture Improvements" section above for migration details. +- **Auto-import changed to manual**: External configurations are no longer auto-imported on startup. Click "Import Current Config" manually when needed. +- **OMO and OMO Slim are mutually exclusive**: Only one can be active at a time. Switching to one automatically disables the other. +- **Backup is enabled by default**: Automatic hourly backup during runtime. Adjust the policy in the Backup panel. + +--- + +## Special Thanks + +Thanks to all contributors for their contributions to this release! + +@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar + +--- + +## Download & Installation + +Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version. + +### System Requirements + +| System | Minimum Version | Architecture | +| ------- | ------------------------------- | ----------------------------------- | +| Windows | Windows 10 or later | x64 | +| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) | +| Linux | See table below | x64 | + +### Windows + +| File | Description | +| ---------------------------------------- | ---------------------------------------------------- | +| `CC-Switch-v3.11.0-Windows.msi` | **Recommended** - MSI installer with auto-update | +| `CC-Switch-v3.11.0-Windows-Portable.zip` | Portable version, extract and run, no registry write | + +### macOS + +| File | Description | +| -------------------------------- | -------------------------------------------------------------------- | +| `CC-Switch-v3.11.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary | +| `CC-Switch-v3.11.0-macOS.tar.gz` | For Homebrew installation and auto-update | + +> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards. + +### Homebrew (macOS) + +```bash +brew tap farion1231/ccswitch +brew install --cask cc-switch +``` + +Update: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +| Distribution | Recommended Format | Installation Method | +| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR | +| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/docs/release-note-v3.11.0-ja.md b/docs/release-note-v3.11.0-ja.md new file mode 100644 index 000000000..ce5d03729 --- /dev/null +++ b/docs/release-note-v3.11.0-ja.md @@ -0,0 +1,302 @@ +# CC Switch v3.11.0 + +> OpenClaw サポート、セッションマネージャー、バックアップ管理と 50 以上の改善 + +**[中文版 →](release-note-v3.11.0-zh.md) | [English →](release-note-v3.11.0-en.md)** + +--- + +## 概要 + +CC Switch v3.11.0 は大規模なアップデートです。5番目のアプリケーション **OpenClaw** の完全管理サポートを追加し、新しい**セッションマネージャー**と**バックアップ管理**機能を導入しました。さらに、**Oh My OpenCode (OMO) 統合**、プロバイダー切り替えの**部分キーフィールドマージ**アーキテクチャアップグレード、**設定ページのリファクタリング**など、多数の改善により全体的な体験がさらに向上しました。 + +**リリース日**: 2026-02-26 + +**更新規模**: 147 commits | 274 files changed | +32,179 / -5,467 lines + +--- + +## ハイライト + +- **OpenClaw サポート**: 5番目の管理対象アプリ、13 のプロバイダープリセット、Env/Tools/AgentsDefaults 設定エディター、Workspace ファイル管理 +- **セッションマネージャー**: 5つのアプリの会話履歴を閲覧、目次ナビゲーションとセッション内検索 +- **バックアップ管理**: 独立バックアップパネル、設定可能なポリシー、定期バックアップ、マイグレーション前自動バックアップ +- **Oh My OpenCode 統合**: 完全な OMO 設定管理、OMO Slim 軽量モードサポート +- **部分キーフィールドマージ(⚠️ 破壊的変更)**: プロバイダー切り替え時にプロバイダー関連フィールドのみ置換し、その他の設定を保持;「共通設定スニペット」機能は削除されました +- **設定ページリファクタリング**: 5タブレイアウト、コード量約 40% 削減 +- **6つの新プロバイダープリセット**: AWS Bedrock、SSAI Code、CrazyRouter、AICoding など +- **Thinking Budget Rectifier**: より精密な thinking budget 制御 +- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション +- **WebDAV 自動同期**: 自動同期と大容量ファイル保護 + +--- + +## 主な機能 + +### OpenClaw サポート(新しい5番目のアプリ) + +Claude Code、Codex、Gemini CLI、OpenCode に続く5番目の管理対象アプリケーションとして OpenClaw の完全管理サポートを追加しました。 + +- **プロバイダー管理**: OpenClaw プロバイダーの追加、編集、切り替え、削除、13 の内蔵プリセット +- **設定エディター**: Env(環境変数)、Tools(ツール)、AgentsDefaults(エージェントデフォルト)の3つの専用パネル +- **Workspace パネル**: HEARTBEAT/BOOTSTRAP/BOOT ファイル管理とデイリーメモリ +- **Additive オーバーレイモード**: 上書きではなく設定の重ね合わせをサポート +- **デフォルトモデルボタン**: ワンクリックで推奨モデルを入力、プロバイダー追加時に候補モデルを allowlist に自動登録 +- **ブランドとインタラクション**: 専用ブランドアイコン、アプリ切り替えフェード遷移アニメーション +- **ディープリンクサポート**: URL 経由で OpenClaw プロバイダー設定をインポート +- **完全な国際化**: 中/英/日 三言語完全対応 + +### セッションマネージャー + +会話履歴を閲覧・検索できる新しいセッションマネージャーです。 + +- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw の5つのアプリの会話履歴を閲覧(#867、@TinsFox に感謝) +- 目次ナビゲーションとセッション内検索 +- セッションページに入ると現在のアプリで自動フィルター +- 並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで読み込みパフォーマンスを最適化 + +### バックアップ管理 + +データの安全性を高める独立バックアップ管理パネルです。 + +- 設定可能なバックアップポリシー: 最大バックアップ数、自動クリーンアップルール +- ランタイム中の1時間ごとの定期自動バックアップ +- データベースマイグレーション前の自動バックアップ、バックフィル警告プロンプト +- バックアップのリネームと削除をサポート(確認ダイアログ付き) +- バックアップファイル名にローカルタイムを使用、より直感的に + +### Oh My OpenCode (OMO) 統合 + +完全な Oh My OpenCode 設定ファイル管理です。 + +- エージェントモデル選択、カテゴリ設定、推奨モデル入力(#972、@yovinchen に感謝) +- エージェントモデル選択 UX の改善、lowercase key 問題の修正(#1004、@yovinchen に感謝) +- OMO Slim 軽量モードサポート +- OMO と OMO Slim の相互排他(データベースレベルで一貫性を保証) + +### ワークスペース + +- デイリーメモリファイルの全文検索、日付順ソート +- ディレクトリパスがクリック可能に、ファイル位置をすばやく開く + +### ツールバー + +- AppSwitcher がウィンドウ幅に応じて自動的にコンパクトモードに折りたたみ +- コンパクトモード切り替えのスムーズ遷移アニメーション + +### 設定 + +- プロキシと使用量機能に初回使用確認ダイアログを追加、誤操作を防止 +- `enableLocalProxy` スイッチを追加、ホーム画面のプロキシ UI 表示を制御 +- より詳細なローカル環境チェック: CLI ツールバージョン検出(#870、@kv-chiu に感謝)、Volta パス検出(#969、@myjustify に感謝) + +### プロバイダープリセット + +- **AWS Bedrock**: AKSK と API Key の2種類の認証方式をサポート(#1047、@keithyt06 に感謝) +- **SSAI Code**: パートナープリセット、5アプリ対応 +- **CrazyRouter**: パートナープリセットと専用アイコン +- **AICoding**: パートナープリセットとプロモーションテキスト +- 国内モデルプロバイダープリセットを最新版に更新 +- Qwen Coder を百炼 (Bailian) にリネーム(#965、@zhu-jl18 に感謝) + +### その他の新機能 + +- **Thinking Budget Rectifier**: より精密な thinking budget 制御(#1005、@yovinchen に感謝) +- **WebDAV 自動同期**: 自動同期設定と大容量ファイル保護(#923、@clx20000410 に感謝;#1043、@SaladDay に感謝) +- **テーマ切り替えアニメーション**: 円形リビール遷移アニメーション(#905、@funnytime75 に感謝) +- **Claude 設定エディタークイックトグル**: よく使う設定項目のクイック切り替え(#1012、@JIA-ss に感謝) +- **動的エンドポイントヒント**: API フォーマット選択に基づく動的ヒントテキスト(#860、@zhu-jl18 に感謝) +- **使用量ダッシュボード強化**: 自動更新、堅牢なフォーマット(#942、@yovinchen に感謝) +- **新しい価格データ**: claude-opus-4-6 と gpt-5.3-codex(#943、@yovinchen に感謝) +- **サイレント起動の最適化**: サイレント起動オプションは自動起動が有効な場合のみ表示 + +--- + +## アーキテクチャ改善 + +### 部分キーフィールドマージ(⚠️ 破壊的変更) + +プロバイダー切り替えを完全な設定上書きから部分キーフィールドマージ戦略に変更しました(#1098)。 + +**変更前**: プロバイダーを切り替えると、`settings_config` 全体がライブ設定ファイルに上書きされていました。つまり、ユーザーがライブファイルに手動で追加した非プロバイダー設定(プラグイン設定、MCP 設定、権限設定など)は、切り替えのたびに失われていました。この問題を補うため、以前のバージョンでは「共通設定スニペット」機能を提供し、毎回の切り替え時にマージされる共通設定を定義できました。 + +**変更後**: プロバイダー切り替え時に、プロバイダー関連のキー値(API キー、エンドポイント、モデルなど)のみが置換され、その他の設定はそのまま保持されます。そのため「共通設定スニペット」機能は不要となり、削除されました。 + +**影響と移行**: +- 共通設定スニペットを**使用していなかった**場合、この変更は完全に透過的で、切り替え体験が向上するだけです +- カスタム設定(MCP 設定、権限など)を保持するために共通設定スニペットを**使用していた**場合、それらの設定は切り替え時に自動的に保持されるようになり、追加の操作は不要です +- 共通設定スニペットを他の目的(切り替え時に追加設定を注入するなど)で使用していた場合は、アップグレード後にライブ設定ファイルに手動で設定を追加してください + +このリファクタリングにより、フロントエンドファイル 6 つ(コンポーネント 3 つ + hooks 3 つ)と約 150 行のバックエンドデッドコードを削除しました。 + +### 手動インポートに変更 + +起動時の自動インポートを廃止し、手動の「現在の設定をインポート」ボタンに変更。意図しないユーザーデータの上書きを防止します。 + +### OmoVariant パラメータ化 + +`OmoVariant` 構造体によるパラメータ化で、OMO モジュールの約250行の重複コードを削除しました。 + +### OMO 共通設定の削除 + +2層マージシステムを削除し、約1,733行のコードを削減、アーキテクチャを簡素化しました。 + +### ProviderForm 分割 + +ProviderForm コンポーネントを2,227行から1,526行に削減し、5つの独立モジュール(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState)に分離。保守性が大幅に向上しました。 + +### MCP/Skills 共有コンポーネント + +AppCountBar、AppToggleGroup、ListItemRow などの共有コンポーネントを抽出し、MCP と Skills パネルの重複コードを削減(#897、@PeanutSplash に感謝)。 + +### 設定ページリファクタリング + +設定ページを5タブレイアウト(一般 | プロキシ | 詳細 | 使用量 | 情報)にリファクタリング。SettingsPage のコードを約716行から約426行に削減しました。 + +### その他の改善 + +- ターミナル統一: グローバル設定でターミナル選択を統一、WezTerm サポートを追加 +- Claude モデル参照を 4.5 から 4.6 に更新 + +--- + +## バグ修正 + +### 重大な修正 + +- **Windows ホームディレクトリ回帰**: デフォルトのホームディレクトリ解決を復元し、Git/MSYS 環境でのデータベースパス変更によるデータ「消失」を防止 +- **Linux 白画面**: AMD GPU の WebKitGTK ハードウェアアクセラレーションを無効化し、一部の Linux システムの起動白画面問題を解決(#986、@ThendCN に感謝) +- **OpenAI Beta パラメータ**: `/v1/chat/completions` に `?beta=true` を追加しないように修正、Nvidia など OpenAI Chat 形式を使用するプロバイダーのリクエスト失敗を修正(#1052、@jnorthrup に感謝) +- **ヘルスチェック認証**: プロバイダーの `auth_mode` 設定を尊重し、Bearer 認証のみをサポートするプロキシサービスのヘルスチェック失敗を回避(#824、@Jassy930 に感謝) + +### プロバイダープリセット修正 + +- OpenClaw `/v1` プレフィックスの二重パス問題を修正 +- Opus 価格修正($15/$75 → $5/$25)と 4.6 へのアップグレード +- AIGoCode URL を `https://api.aigocode.com` に統一 +- Zhipu GLM の古いパートナーステータスを削除 +- 新規 Claude プロバイダー作成時の API Key 入力フィールドの表示を復元 +- 非アクティブプロバイダーのクイックトグルを非表示、コンテキスト対応の JSON エディターヒントを表示 + +### OMO 修正 + +- omo-slim カテゴリチェックの補完(add/form/mutation パス) +- OMO Slim プロバイダー変更後のクエリキャッシュ無効化を修正 +- OMO agent/category 推奨モデルをアップストリームソースと同期 +- 「推奨を入力」ボタン失敗時の toast フィードバックを追加 +- OMO/OMO Slim の最後のプロバイダー削除制限を撤廃 +- OpenCode でモデル未設定時の保存を拒否(#932、@yovinchen に感謝) + +### OpenClaw 修正 + +- 25個の欠落 i18n キー、key={index} を安定 ID に置換、ディープリンク additive マージなどのコードレビュー問題を修正 +- EnvPanel 堅牢性強化(NaN ガード、配列インデックスではなくエントリーキー名を使用) +- i18n 重複キーのマージ、プロバイダーフォーム翻訳を復元 + +### プラットフォーム修正 + +- Windows サイレント起動時のウィンドウフラッシュ(#901、@funnytime75 に感謝) +- タイトルバーのダークモード追従(#903、@funnytime75 に感謝) +- Windows の Skills パスセパレーターマッチング(#868、@stmoonar に感謝) +- WSL ヘルパー関数の条件付きコンパイル + +### UI 修正 + +- ツールバーの高さクリッピングによる AppSwitcher の遮蔽を修正 +- 新バージョンがある場合、緑のチェックマークではなく更新バッジを表示 +- セッションマネージャーボタンを Claude/Codex アプリでのみ表示 +- SQL インポート/エクスポートカードのダークモードスタイルを統一(#1067、@SaladDay に感謝) + +### その他の修正 + +- セッションマネージャーのハードコードされた中国語文字列を i18n キーに置換 +- Skill ドキュメント URL のブランチとパスを修正(#977、@yovinchen に感謝) +- OpenCode install.sh インストールパス検出の補完(#988、@zhu-jl18 に感謝) +- Skill ZIP シンボリックリンク解決の修正(#1040、@yovinchen に感謝) +- MCP フォームに OpenCode チェックボックスを追加(#1026、@yovinchen に感謝) +- useProvidersQuery の自動インポート副作用を削除 + +--- + +## パフォーマンス最適化 + +- セッションパネルの並列ディレクトリスキャン + ヘッドテール JSONL 読み取りで、セッションリスト読み込み速度を大幅向上 +- Tauri ローカル IPC の不要な query cache を削除し、メモリ使用量を削減 + +--- + +## ドキュメント + +- スポンサー更新: SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax +- ユーザーマニュアルを追加(#979、@yovinchen に感謝) + +--- + +## 注意事項 + +- **OpenClaw は新しくサポートされたアプリです**: 関連機能を使用するには、先に OpenClaw CLI をインストールする必要があります。 +- **⚠️ 共通設定スニペット機能は削除されました**: プロバイダー切り替えが部分キーフィールドマージ(API キー、エンドポイント、モデルなどのみ置換)に変更されたため、ユーザーのその他の設定は自動的に保持され、共通設定スニペットは不要になりました。移行の詳細は上記「アーキテクチャ改善」セクションを参照してください。 +- **自動インポートは手動に変更されました**: 起動時に外部設定を自動インポートしなくなりました。必要に応じて「現在の設定をインポート」を手動でクリックしてください。 +- **OMO と OMO Slim は相互排他**: 同時に一つだけ有効にできます。切り替え時にもう一方は自動的に無効になります。 +- **バックアップ機能はデフォルトで有効**: ランタイム中に1時間ごとに自動バックアップします。バックアップパネルでポリシーを調整できます。 + +--- + +## 特別な感謝 + +以下のコントリビューターの皆様、このリリースへの貢献に感謝します! + +@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar + +--- + +## ダウンロードとインストール + +[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。 + +### システム要件 + +| システム | 最小バージョン | アーキテクチャ | +| -------- | -------------------------------- | ----------------------------------- | +| Windows | Windows 10 以降 | x64 | +| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) | +| Linux | 下表参照 | x64 | + +### Windows + +| ファイル | 説明 | +| ---------------------------------------- | ---------------------------------------------------- | +| `CC-Switch-v3.11.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 | +| `CC-Switch-v3.11.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし | + +### macOS + +| ファイル | 説明 | +| -------------------------------- | ----------------------------------------------------------------- | +| `CC-Switch-v3.11.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary | +| `CC-Switch-v3.11.0-macOS.tar.gz` | Homebrew インストールと自動更新用 | + +> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。 + +### Homebrew (macOS) + +```bash +brew tap farion1231/ccswitch +brew install --cask cc-switch +``` + +更新: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +| ディストリビューション | 推奨形式 | インストール方法 | +| --------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 | +| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/docs/release-note-v3.11.0-zh.md b/docs/release-note-v3.11.0-zh.md new file mode 100644 index 000000000..07041c4bb --- /dev/null +++ b/docs/release-note-v3.11.0-zh.md @@ -0,0 +1,302 @@ +# CC Switch v3.11.0 + +> OpenClaw 支持、会话管理器、备份管理与 50+ 项改进 + +**[English →](release-note-v3.11.0-en.md) | [日本語版 →](release-note-v3.11.0-ja.md)** + +--- + +## 概览 + +CC Switch v3.11.0 是一次大规模更新,新增第五个应用 **OpenClaw** 的完整管理支持,同时带来全新的**会话管理器**和**备份管理**功能。此外,**Oh My OpenCode (OMO) 集成**、供应商切换的**部分键值合并**架构升级、**设置页面重构**等多项改进使整体体验更加完善。 + +**发布日期**:2026-02-26 + +**更新规模**:147 commits | 274 files changed | +32,179 / -5,467 lines + +--- + +## 重点内容 + +- **OpenClaw 支持**:第五个受管理应用,含 13 个供应商预设、Env/Tools/AgentsDefaults 配置编辑器、Workspace 文件管理 +- **会话管理器**:浏览五个应用的历史会话,支持目录导航和会话内搜索 +- **备份管理**:独立备份面板,可配置策略、定时备份、迁移前自动备份 +- **Oh My OpenCode 集成**:完整 OMO 配置管理,支持 OMO Slim 轻量模式 +- **部分键值合并(⚠️ 破坏性变更)**:供应商切换改为仅替换供应商相关字段,保留用户的其余设置;"通用配置片段"功能因此移除 +- **设置页面重构**:5 标签页布局,代码量减少约 40% +- **6 组新供应商预设**:AWS Bedrock、SSAI Code、CrazyRouter、AICoding 等 +- **Thinking Budget Rectifier**:代理矫正器,更精细的 thinking budget 控制 +- **主题切换动画**:圆形揭示过渡动画,视觉体验升级 +- **WebDAV 自动同步**:支持自动同步与大文件防护 + +--- + +## 主要功能 + +### OpenClaw 支持(新增第五应用) + +CC Switch 新增对 OpenClaw 的完整管理支持,这是继 Claude Code、Codex、Gemini CLI、OpenCode 之后的第五个受管理应用。 + +- **供应商管理**:新增、编辑、切换、删除 OpenClaw 供应商,含 13 个内置预设 +- **配置编辑器**:Env(环境变量)、Tools(工具)、AgentsDefaults(代理默认值)三个专属配置面板 +- **Workspace 面板**:支持 HEARTBEAT/BOOTSTRAP/BOOT 文件管理及每日记忆 +- **Additive 叠加模式**:支持配置叠加而非覆盖 +- **默认模型按钮**:一键填充推荐模型,添加供应商时自动将建议模型注册到 allowlist +- **品牌与交互**:专属品牌图标、应用切换淡入淡出过渡动画 +- **深链接支持**:通过 URL 导入 OpenClaw 供应商配置 +- **完整国际化**:中/英/日三语全面支持 + +### 会话管理器 Sessions + +全新的会话管理器,帮助你浏览和检索历史会话记录。 + +- 支持浏览 Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw 五个应用的历史会话(#867,感谢 @TinsFox) +- 目录导航和会话内搜索 +- 进入会话页面时默认过滤为当前应用,快速定位 +- 并行目录扫描 + 头尾 JSONL 读取,优化加载性能 + +### 备份管理 Backup + +独立的备份管理面板,让数据安全更有保障。 + +- 可配置备份策略:最大备份数量、自动清理规则 +- 运行时每小时定期自动备份 +- 数据库迁移前自动备份,带回填警告提示 +- 支持备份重命名和删除(含确认对话框) +- 备份文件名使用本地时间,更直观 + +### Oh My OpenCode (OMO) 集成 + +完整的 Oh My OpenCode 配置文件管理。 + +- Agent 模型选择、Category 配置、推荐模型填充(#972,感谢 @yovinchen) +- 改进 Agent 模型选择 UX,修复 lowercase key 问题(#1004,感谢 @yovinchen) +- OMO Slim 轻量模式支持 +- OMO 与 OMO Slim 互斥切换(数据库层级强制保证一致性) + +### 工作空间 Workspace + +- 每日记忆文件全文搜索,按日期排序 +- 目录路径可点击跳转,快速打开文件位置 + +### 工具栏 Toolbar + +- AppSwitcher 根据窗口宽度自动折叠为紧凑模式 +- 紧凑模式切换平滑过渡动画 + +### 设置 Settings + +- 代理和用量功能新增首次使用确认对话框,避免误操作 +- 新增 `enableLocalProxy` 开关,控制主页代理 UI 显示 +- 更精细的本地环境检查:CLI 工具版本检测(#870,感谢 @kv-chiu)、Volta 路径检测(#969,感谢 @myjustify) + +### 供应商预设 Preset + +- **AWS Bedrock**:支持 AKSK 和 API Key 两种认证方式(#1047,感谢 @keithyt06) +- **SSAI Code**:合作伙伴预设,覆盖五端 +- **CrazyRouter**:合作伙伴预设及专属图标 +- **AICoding**:合作伙伴预设及推广文案 +- 更新国内模型供应商预设至最新版本 +- Qwen Coder 重命名为百炼 (Bailian)(#965,感谢 @zhu-jl18) + +### 其他新功能 + +- **Thinking Budget Rectifier**:代理矫正器,更精细地控制 thinking budget 分配(#1005,感谢 @yovinchen) +- **WebDAV 自动同步**:支持自动同步配置,并增加大文件防护(#923,感谢 @clx20000410;#1043,感谢 @SaladDay) +- **主题切换动画**:圆形揭示过渡动画,视觉体验更流畅(#905,感谢 @funnytime75) +- **Claude 配置编辑器快速开关**:快速切换常用配置项(#1012,感谢 @JIA-ss) +- **动态端点提示**:根据 API 格式选择动态显示端点提示文本(#860,感谢 @zhu-jl18) +- **用量仪表盘增强**:自动刷新、更强健的数据格式化(#942,感谢 @yovinchen) +- **新增定价数据**:claude-opus-4-6 和 gpt-5.3-codex(#943,感谢 @yovinchen) +- **静默启动优化**:静默启动选项仅在开机启动开启时显示 + +--- + +## 架构改进 + +### 部分键值合并(⚠️ 破坏性变更) + +供应商切换从全量配置覆写改为部分键值合并策略(#1098)。 + +**变更前**:切换供应商时,整个 `settings_config` 会覆写到 live 配置文件。这意味着用户在 live 文件中手动添加的非供应商设置(插件配置、MCP 配置、权限设置等)会在每次切换时丢失。为了弥补这个问题,之前版本提供了"通用配置片段"功能,让用户定义每次切换时都会合并的公共配置。 + +**变更后**:切换供应商时,仅替换供应商相关的键值(API Key、端点、模型等),用户的其余设置完整保留。因此"通用配置片段"功能不再需要,已被移除。 + +**影响与迁移**: +- 如果你之前**没有使用**通用配置片段功能,此变更对你完全透明,切换体验只会更好 +- 如果你之前**使用了**通用配置片段功能来保留自定义设置(如 MCP 配置、权限等),升级后这些设置会在切换时自动保留,无需额外操作 +- 如果你利用通用配置片段做其他用途(如在切换时注入额外配置),请在升级后手动将这些配置写入 live 配置文件中 + +此次重构删除了 6 个前端文件(3 个组件 + 3 个 hooks)、约 150 行后端死代码。 + +### 手动导入替代自动导入 + +启动时不再自动导入外部配置,改为手动点击"导入当前配置"按钮,避免意外覆盖用户数据。 + +### OMO Variant 参数化 + +通过 `OmoVariant` 结构体参数化消除 OMO 模块约 250 行重复代码。 + +### OMO 公共配置移除 + +删除二层合并系统,减少约 1,733 行代码,简化架构。 + +### ProviderForm 拆分 + +ProviderForm 组件从 2,227 行减至 1,526 行,提取 5 个独立模块(opencodeFormUtils、useOmoModelSource、useOpencodeFormState、useOmoDraftState、useOpenclawFormState),可维护性显著提升。 + +### MCP/Skills 共享组件 + +提取 AppCountBar、AppToggleGroup、ListItemRow 等共享组件,减少 MCP 和 Skills 面板的重复代码(#897,感谢 @PeanutSplash)。 + +### 设置页面重构 + +设置页面重构为 5 标签页布局(通用 | 代理 | 高级 | 用量 | 关于),SettingsPage 代码从约 716 行减至约 426 行。 + +### 其他改进 + +- 终端统一:全局设置统一终端选择,新增 WezTerm 支持 +- Claude 模型引用从 4.5 更新到 4.6 + +--- + +## Bug 修复 + +### 严重修复 + +- **Windows 主目录回归**:恢复默认主目录解析,防止 Git/MSYS 环境下数据库路径变更导致数据"丢失" +- **Linux 白屏**:禁用 AMD GPU 的 WebKitGTK 硬件加速,解决部分 Linux 系统启动白屏问题(#986,感谢 @ThendCN) +- **OpenAI Beta 参数**:不再为 `/v1/chat/completions` 添加 `?beta=true`,修复 Nvidia 等使用 OpenAI Chat 格式的供应商请求失败(#1052,感谢 @jnorthrup) +- **健康检查认证**:尊重供应商 `auth_mode` 设置,避免仅支持 Bearer 认证的代理服务健康检查失败(#824,感谢 @Jassy930) + +### 供应商预设修复 + +- 修复 OpenClaw `/v1` 前缀双重路径问题 +- Opus 定价修正($15/$75 → $5/$25)并升级到 4.6 +- AIGoCode URL 统一为 `https://api.aigocode.com` +- Zhipu GLM 移除过时合作伙伴状态 +- 新建 Claude 供应商时 API Key 输入框可见性恢复 +- 非活跃供应商隐藏快速开关,显示上下文感知的 JSON 编辑器提示 + +### OMO 修复 + +- omo-slim 分类检查补齐(add/form/mutation 路径) +- OMO Slim 供应商变更后正确失效查询缓存 +- OMO agent/category 推荐模型与上游源同步 +- "填充推荐"按钮失败时增加 toast 反馈 +- 移除 OMO/OMO Slim 最后一个供应商的删除限制 +- OpenCode 未配置模型时拒绝保存(#932,感谢 @yovinchen) + +### OpenClaw 修复 + +- 修复 25 个缺失 i18n key、替换 key={index} 为稳定 ID、深链接 additive 合并等代码审查问题 +- EnvPanel 健壮性增强(NaN 守卫、使用条目键名而非数组索引) +- i18n 重复键合并,恢复供应商表单翻译 + +### 平台修复 + +- Windows 静默启动时窗口闪烁(#901,感谢 @funnytime75) +- 标题栏暗黑模式跟随主题(#903,感谢 @funnytime75) +- Windows Skills 路径分隔符匹配(#868,感谢 @stmoonar) +- WSL 辅助函数条件编译 + +### UI 修复 + +- 工具栏高度裁切导致 AppSwitcher 被遮挡 +- 有新版本时显示更新徽章而非绿色对勾 +- 仅 Claude/Codex 应用显示会话管理器按钮 +- SQL 导入/导出卡片暗黑模式样式统一(#1067,感谢 @SaladDay) + +### 其他修复 + +- 会话管理器硬编码中文字符串替换为 i18n key +- Skill 文档 URL 分支和路径修正(#977,感谢 @yovinchen) +- OpenCode install.sh 安装路径检测补齐(#988,感谢 @zhu-jl18) +- Skill ZIP 符号链接解析修复(#1040,感谢 @yovinchen) +- MCP 表单补齐 OpenCode 复选框(#1026,感谢 @yovinchen) +- useProvidersQuery 中自动导入副作用移除 + +--- + +## 性能优化 + +- 会话面板并行目录扫描 + 头尾 JSONL 读取,大幅提升会话列表加载速度 +- 移除 Tauri 本地 IPC 不必要的 query cache,减少内存占用 + +--- + +## 文档 + +- 赞助商更新:SSSAiCode、Crazyrouter、AICoding、Right Code、MiniMax +- 新增用户手册(#979,感谢 @yovinchen) + +--- + +## 说明与注意事项 + +- **OpenClaw 为新支持的应用**:需要先安装 OpenClaw CLI 才能使用相关功能。 +- **⚠️ 通用配置片段功能已移除**:由于供应商切换改为部分键值合并(仅替换 API Key、端点、模型等字段),用户的其余设置会自动保留,"通用配置片段"功能不再需要。详见上方"架构改进"章节的迁移说明。 +- **自动导入已改为手动**:启动时不再自动导入外部配置,请在需要时手动点击"导入当前配置"。 +- **OMO 与 OMO Slim 互斥**:同一时间只能启用其中一个,切换时另一个会自动禁用。 +- **备份功能默认开启**:运行时每小时自动备份,可在备份面板调整策略。 + +--- + +## 特别感谢 + +感谢以下贡献者为本版本做出的贡献! + +@TinsFox @keithyt06 @kv-chiu @SaladDay @jnorthrup @JIA-ss @clx20000410 @ThendCN @yovinchen @zhu-jl18 @myjustify @funnytime75 @PeanutSplash @Jassy930 @stmoonar + +--- + +## 下载与安装 + +访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。 + +### 系统要求 + +| 系统 | 最低版本 | 架构 | +| ------- | ----------------------------- | ----------------------------------- | +| Windows | Windows 10 及以上 | x64 | +| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) | +| Linux | 见下表 | x64 | + +### Windows + +| 文件 | 说明 | +| ---------------------------------------- | ----------------------------------- | +| `CC-Switch-v3.11.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 | +| `CC-Switch-v3.11.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 | + +### macOS + +| 文件 | 说明 | +| -------------------------------- | --------------------------------------------------------- | +| `CC-Switch-v3.11.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary | +| `CC-Switch-v3.11.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 | + +> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开 + +### Homebrew(macOS) + +```bash +brew tap farion1231/ccswitch +brew install --cask cc-switch +``` + +更新: + +```bash +brew upgrade --cask cc-switch +``` + +### Linux + +| 发行版 | 推荐格式 | 安装方式 | +| --------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` | +| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` | +| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` | +| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR | +| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` | diff --git a/docs/user-manual/README.md b/docs/user-manual/README.md index 057c292df..01ce206ae 100644 --- a/docs/user-manual/README.md +++ b/docs/user-manual/README.md @@ -99,9 +99,9 @@ ## 版本信息 -- 文档版本:v3.10.3 -- 最后更新:2026-02-09 -- 适用于 CC Switch v3.10.0+ +- 文档版本:v3.11.0 +- 最后更新:2026-02-26 +- 适用于 CC Switch v3.11.0+ ## 贡献 diff --git a/package.json b/package.json index 62c1c7177..5502c8db3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.10.3", + "version": "3.11.0", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bd5fd8c38..7d204de25 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.10.3" +version = "3.11.0" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3dd7f59a..b5ce8ee7a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.10.3" +version = "3.11.0" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index ca7759f4c..54c996695 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -7,7 +7,6 @@ use tauri_plugin_opener::OpenerExt; use crate::app_config::AppType; use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; -use crate::settings; #[tauri::command] pub async fn get_claude_config_status() -> Result { @@ -16,18 +15,6 @@ pub async fn get_claude_config_status() -> Result { use std::str::FromStr; -fn invalid_json_format_error(error: serde_json::Error) -> String { - let lang = settings::get_settings() - .language - .unwrap_or_else(|| "zh".to_string()); - - match lang.as_str() { - "en" => format!("Invalid JSON format: {error}"), - "ja" => format!("JSON形式が無効です: {error}"), - _ => format!("无效的 JSON 格式: {error}"), - } -} - #[tauri::command] pub async fn get_config_status(app: String) -> Result { match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -163,71 +150,3 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { Ok(true) } - -#[tauri::command] -pub async fn get_common_config_snippet( - app_type: String, - state: tauri::State<'_, crate::store::AppState>, -) -> Result, String> { - state - .db - .get_config_snippet(&app_type) - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn set_common_config_snippet( - app_type: String, - snippet: String, - state: tauri::State<'_, crate::store::AppState>, -) -> Result<(), String> { - if !snippet.trim().is_empty() { - match app_type.as_str() { - "claude" | "gemini" | "omo" | "omo-slim" => { - serde_json::from_str::(&snippet) - .map_err(invalid_json_format_error)?; - } - "codex" => {} - _ => {} - } - } - - let value = if snippet.trim().is_empty() { - None - } else { - Some(snippet) - }; - - state - .db - .set_config_snippet(&app_type, value) - .map_err(|e| e.to_string())?; - - if app_type == "omo" - && state - .db - .get_current_omo_provider("opencode", "omo") - .map_err(|e| e.to_string())? - .is_some() - { - crate::services::OmoService::write_config_to_file( - state.inner(), - &crate::services::omo::STANDARD, - ) - .map_err(|e| e.to_string())?; - } - if app_type == "omo-slim" - && state - .db - .get_current_omo_provider("opencode", "omo-slim") - .map_err(|e| e.to_string())? - .is_some() - { - crate::services::OmoService::write_config_to_file( - state.inner(), - &crate::services::omo::SLIM, - ) - .map_err(|e| e.to_string())?; - } - Ok(()) -} diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 42ee6b209..be2fc3d33 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -123,6 +123,24 @@ pub async fn open_zip_file_dialog( // ─── Database backup management ───────────────────────────── +/// Manually create a database backup +#[tauri::command] +pub async fn create_db_backup(state: State<'_, AppState>) -> Result { + 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, String> { @@ -150,3 +168,9 @@ pub fn rename_db_backup( ) -> Result { 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()) +} diff --git a/src-tauri/src/commands/omo.rs b/src-tauri/src/commands/omo.rs index d976e8b94..628cc2853 100644 --- a/src-tauri/src/commands/omo.rs +++ b/src-tauri/src/commands/omo.rs @@ -36,19 +36,6 @@ pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), Strin Ok(()) } -#[tauri::command] -pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result { - let providers = state - .db - .get_all_providers("opencode") - .map_err(|e| e.to_string())?; - let count = providers - .values() - .filter(|p| p.category.as_deref() == Some("omo")) - .count(); - Ok(count) -} - // ── OMO Slim commands ─────────────────────────────────────── #[tauri::command] @@ -84,16 +71,3 @@ pub async fn disable_current_omo_slim(state: State<'_, AppState>) -> Result<(), OmoService::delete_config_file(&SLIM).map_err(|e| e.to_string())?; Ok(()) } - -#[tauri::command] -pub async fn get_omo_slim_provider_count(state: State<'_, AppState>) -> Result { - let providers = state - .db - .get_all_providers("opencode") - .map_err(|e| e.to_string())?; - let count = providers - .values() - .filter(|p| p.category.as_deref() == Some("omo-slim")) - .count(); - Ok(count) -} diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index b3d98df89..42c261aaa 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -5,7 +5,7 @@ use super::{lock_conn, Database}; use crate::config::get_app_config_dir; use crate::error::AppError; -use chrono::Utc; +use chrono::{Local, Utc}; use rusqlite::backup::Backup; use rusqlite::types::ValueRef; use rusqlite::Connection; @@ -182,7 +182,7 @@ impl Database { fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; - let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); + let base_id = format!("db_backup_{}", Local::now().format("%Y%m%d_%H%M%S")); let mut backup_id = base_id.clone(); let mut backup_path = backup_dir.join(format!("{backup_id}.db")); let mut counter = 1; @@ -531,4 +531,29 @@ impl Database { log::info!("Renamed backup: {old_filename} -> {new_filename}"); Ok(new_filename) } + + /// Delete a backup file permanently. + pub fn delete_backup(filename: &str) -> Result<(), AppError> { + // Validate filename (path traversal + .db suffix) + if filename.contains("..") + || filename.contains('/') + || filename.contains('\\') + || !filename.ends_with(".db") + { + return Err(AppError::InvalidInput( + "Invalid backup filename".to_string(), + )); + } + + let backup_path = get_app_config_dir().join("backups").join(filename); + if !backup_path.exists() { + return Err(AppError::InvalidInput(format!( + "Backup file not found: {filename}" + ))); + } + + fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?; + log::info!("Deleted backup: {filename}"); + Ok(()) + } } diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 53ea93dfa..81ac6a6c1 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -4,7 +4,6 @@ pub mod failover; pub mod mcp; -pub mod omo; pub mod prompts; pub mod providers; pub mod proxy; @@ -16,4 +15,3 @@ pub mod universal_providers; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 导出 FailoverQueueItem 供外部使用 pub use failover::FailoverQueueItem; -pub use omo::OmoGlobalConfig; diff --git a/src-tauri/src/database/dao/omo.rs b/src-tauri/src/database/dao/omo.rs deleted file mode 100644 index 85be1ab22..000000000 --- a/src-tauri/src/database/dao/omo.rs +++ /dev/null @@ -1,77 +0,0 @@ -use crate::database::Database; -use crate::error::AppError; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OmoGlobalConfig { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub schema_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sisyphus_agent: Option, - #[serde(default)] - pub disabled_agents: Vec, - #[serde(default)] - pub disabled_mcps: Vec, - #[serde(default)] - pub disabled_hooks: Vec, - #[serde(default)] - pub disabled_skills: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub lsp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub experimental: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub background_task: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub browser_automation_engine: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub claude_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub other_fields: Option, - pub updated_at: String, -} - -impl Default for OmoGlobalConfig { - fn default() -> Self { - Self { - id: "global".to_string(), - schema_url: None, - sisyphus_agent: None, - disabled_agents: vec![], - disabled_mcps: vec![], - disabled_hooks: vec![], - disabled_skills: vec![], - lsp: None, - experimental: None, - background_task: None, - browser_automation_engine: None, - claude_code: None, - other_fields: None, - updated_at: chrono::Utc::now().to_rfc3339(), - } - } -} - -impl Database { - pub fn get_omo_global_config(&self, key: &str) -> Result { - let json_str = self.get_setting(key)?; - match json_str { - Some(s) => serde_json::from_str::(&s) - .map_err(|e| AppError::Config(format!("Failed to parse {key}: {e}"))), - None => Ok(OmoGlobalConfig::default()), - } - } - - pub fn save_omo_global_config( - &self, - key: &str, - config: &OmoGlobalConfig, - ) -> Result<(), AppError> { - let json_str = serde_json::to_string(config) - .map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?; - self.set_setting(key, &json_str)?; - Ok(()) - } -} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 4f4259e95..bf0a44361 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -375,6 +375,19 @@ impl Database { params![app_type, category], ) .map_err(|e| AppError::Database(e.to_string()))?; + // OMO ↔ OMO Slim mutually exclusive: deactivate the opposite category + let opposite = match category { + "omo" => Some("omo-slim"), + "omo-slim" => Some("omo"), + _ => None, + }; + if let Some(opp) = opposite { + tx.execute( + "UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = ?2", + params![app_type, opp], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } let updated = tx .execute( "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = ?3", diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 8056a01cb..0b51a62e7 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -38,31 +38,6 @@ impl Database { Ok(()) } - // --- Config Snippets 辅助方法 --- - - /// 获取通用配置片段 - pub fn get_config_snippet(&self, app_type: &str) -> Result, AppError> { - self.get_setting(&format!("common_config_{app_type}")) - } - - /// 设置通用配置片段 - pub fn set_config_snippet( - &self, - app_type: &str, - snippet: Option, - ) -> 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 的存储键名 diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 9ec7ea608..b5ac00677 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -33,7 +33,6 @@ mod tests; // DAO 类型导出供外部使用 pub use dao::FailoverQueueItem; -pub use dao::OmoGlobalConfig; use crate::config::get_app_config_dir; use crate::error::AppError; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2cefc38c..1ba9229da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -845,8 +845,6 @@ pub fn run() { commands::get_skills_migration_result, commands::get_app_config_path, commands::open_app_config_folder, - commands::get_common_config_snippet, - commands::set_common_config_snippet, commands::read_live_provider_settings, commands::patch_claude_live_settings, commands::get_settings, @@ -913,9 +911,11 @@ pub fn run() { commands::save_file_dialog, commands::open_file_dialog, commands::open_zip_file_dialog, + commands::create_db_backup, commands::list_db_backups, commands::restore_db_backup, commands::rename_db_backup, + commands::delete_db_backup, commands::sync_current_providers_live, // Deep link import commands::parse_deeplink, @@ -1037,11 +1037,9 @@ pub fn run() { commands::set_window_theme, commands::read_omo_local_file, commands::get_current_omo_provider_id, - commands::get_omo_provider_count, commands::disable_current_omo, commands::read_omo_slim_local_file, commands::get_current_omo_slim_provider_id, - commands::get_omo_slim_provider_count, commands::disable_current_omo_slim, // Workspace files (OpenClaw) commands::read_workspace_file, diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs index b62ac26ff..49d5be92c 100644 --- a/src-tauri/src/services/omo.rs +++ b/src-tauri/src/services/omo.rs @@ -1,5 +1,4 @@ use crate::config::write_json_file; -use crate::database::OmoGlobalConfig; use crate::error::AppError; use crate::opencode_config::get_opencode_dir; use crate::store::AppState; @@ -13,12 +12,11 @@ pub struct OmoLocalFileData { pub agents: Option, pub categories: Option, pub other_fields: Option, - pub global: OmoGlobalConfig, pub file_path: String, pub last_modified: Option, } -type OmoProfileData = (Option, Option, Option, bool); +type OmoProfileData = (Option, Option, Option); // ── Variant descriptor ───────────────────────────────────────── @@ -28,9 +26,7 @@ pub struct OmoVariant { pub provider_prefix: &'static str, pub plugin_name: &'static str, pub plugin_prefix: &'static str, - pub known_keys: &'static [&'static str], pub has_categories: bool, - pub config_key: &'static str, pub label: &'static str, pub import_label: &'static str, } @@ -41,23 +37,7 @@ pub const STANDARD: OmoVariant = OmoVariant { provider_prefix: "omo-", plugin_name: "oh-my-opencode@latest", plugin_prefix: "oh-my-opencode", - known_keys: &[ - "$schema", - "agents", - "categories", - "sisyphus_agent", - "disabled_agents", - "disabled_mcps", - "disabled_hooks", - "disabled_skills", - "lsp", - "experimental", - "background_task", - "browser_automation_engine", - "claude_code", - ], has_categories: true, - config_key: "common_config_omo", label: "OMO", import_label: "Imported", }; @@ -68,18 +48,7 @@ pub const SLIM: OmoVariant = OmoVariant { provider_prefix: "omo-slim-", plugin_name: "oh-my-opencode-slim@latest", plugin_prefix: "oh-my-opencode-slim", - known_keys: &[ - "$schema", - "agents", - "sisyphus_agent", - "disabled_agents", - "disabled_mcps", - "disabled_hooks", - "lsp", - "experimental", - ], has_categories: false, - config_key: "common_config_omo_slim", label: "OMO Slim", import_label: "Imported Slim", }; @@ -135,47 +104,6 @@ impl OmoService { other } - fn extract_string_array(val: &Value) -> Vec { - 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, global: &mut OmoGlobalConfig) { - if let Some(v) = obj.get("$schema") { - global.schema_url = v.as_str().map(|s| s.to_string()); - } - for (key, target) in [ - ("disabled_agents", &mut global.disabled_agents), - ("disabled_mcps", &mut global.disabled_mcps), - ("disabled_hooks", &mut global.disabled_hooks), - ("disabled_skills", &mut global.disabled_skills), - ] { - if let Some(v) = obj.get(key) { - *target = Self::extract_string_array(v); - } - } - for (key, target) in [ - ("sisyphus_agent", &mut global.sisyphus_agent), - ("lsp", &mut global.lsp), - ("experimental", &mut global.experimental), - ("background_task", &mut global.background_task), - ( - "browser_automation_engine", - &mut global.browser_automation_engine, - ), - ("claude_code", &mut global.claude_code), - ] { - if let Some(v) = obj.get(key) { - *target = Some(v.clone()); - } - } - } - // ── Merge helpers ────────────────────────────────────── fn insert_opt_value(result: &mut Map, key: &str, value: &Option) { @@ -184,15 +112,6 @@ impl OmoService { } } - fn insert_string_array(result: &mut Map, 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, value: Option<&Value>) { if let Some(Value::Object(map)) = value { for (k, v) in map { @@ -214,9 +133,7 @@ impl OmoService { } pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> { - let global = state.db.get_omo_global_config(v.config_key)?; let current_omo = state.db.get_current_omo_provider("opencode", v.category)?; - let profile_data = current_omo.as_ref().map(|p| { let agents = p.settings_config.get("agents").cloned(); let categories = if v.has_categories { @@ -225,15 +142,10 @@ impl OmoService { None }; let other_fields = p.settings_config.get("otherFields").cloned(); - let use_common_config = p - .settings_config - .get("useCommonConfig") - .and_then(|val| val.as_bool()) - .unwrap_or(true); - (agents, categories, other_fields, use_common_config) + (agents, categories, other_fields) }); - let merged = Self::merge_config(v, &global, profile_data.as_ref()); + let merged = Self::build_config(v, profile_data.as_ref()); let config_path = Self::config_path(v); if let Some(parent) = config_path.parent() { @@ -241,56 +153,20 @@ impl OmoService { } write_json_file(&config_path, &merged)?; - crate::opencode_config::add_plugin(v.plugin_name)?; - log::info!("{} config written to {config_path:?}", v.label); Ok(()) } - fn merge_config( - v: &OmoVariant, - global: &OmoGlobalConfig, - profile_data: Option<&OmoProfileData>, - ) -> Value { + fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value { let mut result = Map::new(); - let use_common_config = profile_data.map(|(_, _, _, uc)| *uc).unwrap_or(true); - - if use_common_config { - if let Some(url) = &global.schema_url { - result.insert("$schema".to_string(), Value::String(url.clone())); - } - - Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent); - Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents); - Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps); - Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks); - - if v.has_categories { - Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills); - Self::insert_opt_value(&mut result, "background_task", &global.background_task); - Self::insert_opt_value( - &mut result, - "browser_automation_engine", - &global.browser_automation_engine, - ); - Self::insert_opt_value(&mut result, "claude_code", &global.claude_code); - } - - Self::insert_opt_value(&mut result, "lsp", &global.lsp); - Self::insert_opt_value(&mut result, "experimental", &global.experimental); - - Self::insert_object_entries(&mut result, global.other_fields.as_ref()); - } - - if let Some((agents, categories, other_fields, _)) = profile_data { + if let Some((agents, categories, other_fields)) = profile_data { + Self::insert_object_entries(&mut result, other_fields.as_ref()); Self::insert_opt_value(&mut result, "agents", agents); if v.has_categories { Self::insert_opt_value(&mut result, "categories", categories); } - Self::insert_object_entries(&mut result, other_fields.as_ref()); } - Value::Object(result) } @@ -310,18 +186,12 @@ impl OmoService { settings.insert("categories".to_string(), categories.clone()); } } - settings.insert("useCommonConfig".to_string(), Value::Bool(true)); - let other = Self::extract_other_fields_with_keys(&obj, v.known_keys); + let other = Self::extract_other_fields_with_keys(&obj, &["agents", "categories"]); if !other.is_empty() { settings.insert("otherFields".to_string(), Value::Object(other)); } - let mut global = state.db.get_omo_global_config(v.config_key)?; - Self::merge_global_from_obj(&obj, &mut global); - global.updated_at = chrono::Utc::now().to_rfc3339(); - state.db.save_omo_global_config(v.config_key, &global)?; - let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4()); let name = format!( "{} {}", @@ -384,22 +254,17 @@ impl OmoService { None }; - let other = Self::extract_other_fields_with_keys(obj, v.known_keys); + let other = Self::extract_other_fields_with_keys(obj, &["agents", "categories"]); let other_fields = if other.is_empty() { None } else { Some(Value::Object(other)) }; - let mut global = OmoGlobalConfig::default(); - Self::merge_global_from_obj(obj, &mut global); - global.other_fields = other_fields.clone(); - OmoLocalFileData { agents, categories, other_fields, - global, file_path, last_modified, } @@ -482,26 +347,24 @@ mod tests { } #[test] - fn test_merge_config_empty() { - let global = OmoGlobalConfig::default(); - let merged = OmoService::merge_config(&STANDARD, &global, None); + fn test_build_config_empty() { + let merged = OmoService::build_config(&STANDARD, None); assert!(merged.is_object()); + assert!(merged.as_object().unwrap().is_empty()); } #[test] - fn test_merge_config_with_profile() { - let global = OmoGlobalConfig { - schema_url: Some("https://example.com/schema.json".to_string()), - disabled_agents: vec!["explore".to_string()], - ..Default::default() - }; + fn test_build_config_with_profile() { let agents = Some(serde_json::json!({ "sisyphus": { "model": "claude-opus-4-5" } })); let categories = None; - let other_fields = None; - let profile_data = (agents, categories, other_fields, true); - let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data)); + let other_fields = Some(serde_json::json!({ + "$schema": "https://example.com/schema.json", + "disabled_agents": ["explore"] + })); + let profile_data = (agents, categories, other_fields); + let merged = OmoService::build_config(&STANDARD, Some(&profile_data)); let obj = merged.as_object().unwrap(); assert_eq!(obj["$schema"], "https://example.com/schema.json"); @@ -511,28 +374,7 @@ mod tests { } #[test] - fn test_merge_config_without_common_config() { - let global = OmoGlobalConfig { - schema_url: Some("https://example.com/schema.json".to_string()), - disabled_agents: vec!["explore".to_string()], - ..Default::default() - }; - let agents = Some(serde_json::json!({ - "sisyphus": { "model": "claude-opus-4-5" } - })); - let categories = None; - let other_fields = None; - let profile_data = (agents, categories, other_fields, false); - let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data)); - let obj = merged.as_object().unwrap(); - - assert!(!obj.contains_key("$schema")); - assert!(!obj.contains_key("disabled_agents")); - assert!(obj.contains_key("agents")); - } - - #[test] - fn test_build_local_file_data_keeps_unknown_top_level_fields_in_global() { + fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() { let obj = serde_json::json!({ "$schema": "https://example.com/schema.json", "disabled_agents": ["oracle"], @@ -555,64 +397,53 @@ mod tests { None, ); + // All non-agents/categories fields should be in other_fields + let other = data.other_fields.unwrap(); + let other_obj = other.as_object().unwrap(); assert_eq!( - data.global.schema_url.as_deref(), - Some("https://example.com/schema.json") + other_obj.get("$schema").unwrap(), + "https://example.com/schema.json" ); - assert_eq!(data.global.disabled_agents, vec!["oracle".to_string()]); - assert_eq!( - data.other_fields, - Some(serde_json::json!({ - "custom_top_level": { "enabled": true } - })) + other_obj.get("disabled_agents").unwrap(), + &serde_json::json!(["oracle"]) ); - assert_eq!(data.global.other_fields, data.other_fields); + assert_eq!( + other_obj.get("custom_top_level").unwrap(), + &serde_json::json!({"enabled": true}) + ); + // agents and categories should NOT be in other_fields + assert!(!other_obj.contains_key("agents")); + assert!(!other_obj.contains_key("categories")); } #[test] - fn test_merge_config_ignores_non_object_other_fields() { - let global = OmoGlobalConfig { - other_fields: Some(serde_json::json!(["global_non_object"])), - ..Default::default() - }; + fn test_build_config_ignores_non_object_other_fields() { let agents = None; let categories = None; let other_fields = Some(serde_json::json!("profile_non_object")); - let profile_data = (agents, categories, other_fields, true); + let profile_data = (agents, categories, other_fields); - let merged = OmoService::merge_config(&STANDARD, &global, Some(&profile_data)); + let merged = OmoService::build_config(&STANDARD, Some(&profile_data)); let obj = merged.as_object().unwrap(); - assert!(!obj.contains_key("0")); - assert!(!obj.contains_key("global_non_object")); assert!(!obj.contains_key("profile_non_object")); } #[test] - fn test_merge_config_slim_excludes_categories_and_extra_fields() { - let global = OmoGlobalConfig { - schema_url: Some("https://slim.schema".to_string()), - disabled_agents: vec!["oracle".to_string()], - disabled_skills: vec!["playwright".to_string()], - background_task: Some(serde_json::json!({"key": "val"})), - browser_automation_engine: Some(serde_json::json!({"provider": "pw"})), - claude_code: Some(serde_json::json!({"mcp": true})), - ..Default::default() - }; + fn test_build_config_slim_excludes_categories() { let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}})); let categories = Some(serde_json::json!({"code": {"model": "gpt"}})); - let other_fields = None; - let profile_data = (agents, categories, other_fields, true); + let other_fields = Some(serde_json::json!({ + "$schema": "https://slim.schema", + "disabled_agents": ["oracle"] + })); + let profile_data = (agents, categories, other_fields); - let merged = OmoService::merge_config(&SLIM, &global, Some(&profile_data)); + let merged = OmoService::build_config(&SLIM, Some(&profile_data)); let obj = merged.as_object().unwrap(); - // Slim should NOT include these - assert!(!obj.contains_key("disabled_skills")); - assert!(!obj.contains_key("background_task")); - assert!(!obj.contains_key("browser_automation_engine")); - assert!(!obj.contains_key("claude_code")); + // Slim should NOT include categories assert!(!obj.contains_key("categories")); // Slim SHOULD include these diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index fdd9daa51..59f4b8536 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -260,18 +260,6 @@ impl ProviderService { state .db .is_omo_provider_current(app_type.as_str(), id, "omo")?; - let omo_count = state - .db - .get_all_providers(app_type.as_str())? - .values() - .filter(|p| p.category.as_deref() == Some("omo")) - .count(); - - if omo_count <= 1 && was_current { - return Err(AppError::Message( - "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), - )); - } state.db.delete_provider(app_type.as_str(), id)?; if was_current { @@ -287,18 +275,6 @@ impl ProviderService { state .db .is_omo_provider_current(app_type.as_str(), id, "omo-slim")?; - let slim_count = state - .db - .get_all_providers(app_type.as_str())? - .values() - .filter(|p| p.category.as_deref() == Some("omo-slim")) - .count(); - - if slim_count <= 1 && was_current { - return Err(AppError::Message( - "无法删除当前启用的最后一个 OMO Slim 配置,请先停用".to_string(), - )); - } state.db.delete_provider(app_type.as_str(), id)?; if was_current { @@ -513,6 +489,8 @@ impl ProviderService { state, &crate::services::omo::STANDARD, )?; + // OMO ↔ OMO Slim mutually exclusive: remove Slim config + let _ = crate::services::OmoService::delete_config_file(&crate::services::omo::SLIM); return Ok(SwitchResult::default()); } @@ -522,6 +500,9 @@ impl ProviderService { .db .set_omo_provider_current(app_type.as_str(), id, "omo-slim")?; crate::services::OmoService::write_config_to_file(state, &crate::services::omo::SLIM)?; + // OMO ↔ OMO Slim mutually exclusive: remove Standard config + let _ = + crate::services::OmoService::delete_config_file(&crate::services::omo::STANDARD); return Ok(SwitchResult::default()); } diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 8d833636f..f7ab61e98 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -37,12 +37,27 @@ pub struct SessionMessage { } pub fn scan_sessions() -> Vec { + let (r1, r2, r3, r4, r5) = std::thread::scope(|s| { + let h1 = s.spawn(codex::scan_sessions); + let h2 = s.spawn(claude::scan_sessions); + let h3 = s.spawn(opencode::scan_sessions); + let h4 = s.spawn(openclaw::scan_sessions); + let h5 = s.spawn(gemini::scan_sessions); + ( + h1.join().unwrap_or_default(), + h2.join().unwrap_or_default(), + h3.join().unwrap_or_default(), + h4.join().unwrap_or_default(), + h5.join().unwrap_or_default(), + ) + }); + let mut sessions = Vec::new(); - sessions.extend(codex::scan_sessions()); - sessions.extend(claude::scan_sessions()); - sessions.extend(opencode::scan_sessions()); - sessions.extend(openclaw::scan_sessions()); - sessions.extend(gemini::scan_sessions()); + sessions.extend(r1); + sessions.extend(r2); + sessions.extend(r3); + sessions.extend(r4); + sessions.extend(r5); sessions.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index b953c015d..6f1d8f20a 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -7,7 +7,9 @@ use serde_json::Value; use crate::config::get_claude_config_dir; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; +use super::utils::{ + extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, +}; const PROVIDER_ID: &str = "claude"; @@ -73,60 +75,61 @@ fn parse_session(path: &Path) -> Option { return None; } - let file = File::open(path).ok()?; - let reader = BufReader::new(file); + let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; - let mut last_active_at: Option = None; - let mut summary: Option = None; - for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; - let value: Value = match serde_json::from_str(&line) { + // Extract metadata from head lines + for line in &head { + let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; - if session_id.is_none() { session_id = value .get("sessionId") .and_then(Value::as_str) .map(|s| s.to_string()); } - if project_dir.is_none() { project_dir = value .get("cwd") .and_then(Value::as_str) .map(|s| s.to_string()); } - - if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { - if created_at.is_none() { - created_at = Some(ts); - } - last_active_at = Some(ts); + if created_at.is_none() { + created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } + } - if value.get("isMeta").and_then(Value::as_bool) == Some(true) { - continue; - } + // Extract last_active_at and summary from tail lines (reverse order) + let mut last_active_at: Option = None; + let mut summary: Option = None; - let message = match value.get("message") { - Some(message) => message, - None => continue, + for line in tail.iter().rev() { + let value: Value = match serde_json::from_str(line) { + Ok(parsed) => parsed, + Err(_) => continue, }; - - let text = message.get("content").map(extract_text).unwrap_or_default(); - if text.trim().is_empty() { - continue; + if last_active_at.is_none() { + last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms); + } + if summary.is_none() { + if value.get("isMeta").and_then(Value::as_bool) == Some(true) { + continue; + } + if let Some(message) = value.get("message") { + let text = message.get("content").map(extract_text).unwrap_or_default(); + if !text.trim().is_empty() { + summary = Some(text); + } + } + } + if last_active_at.is_some() && summary.is_some() { + break; } - summary = Some(text); } let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 6c2b62b58..0fd1e9656 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -1,6 +1,7 @@ use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; use regex::Regex; use serde_json::Value; @@ -8,10 +9,17 @@ use serde_json::Value; use crate::codex_config::get_codex_config_dir; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; +use super::utils::{ + extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, +}; const PROVIDER_ID: &str = "codex"; +static UUID_RE: LazyLock = 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 { let root = get_codex_config_dir().join("sessions"); let mut files = Vec::new(); @@ -74,32 +82,21 @@ pub fn load_messages(path: &Path) -> Result, String> { } fn parse_session(path: &Path) -> Option { - let file = File::open(path).ok()?; - let reader = BufReader::new(file); + let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; - let mut last_active_at: Option = None; - let mut summary: Option = None; - for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; - let value: Value = match serde_json::from_str(&line) { + // Extract metadata from head lines + for line in &head { + let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; - - if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { - if created_at.is_none() { - created_at = Some(ts); - } - last_active_at = Some(ts); + if created_at.is_none() { + created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } - if value.get("type").and_then(Value::as_str) == Some("session_meta") { if let Some(payload) = value.get("payload") { if session_id.is_none() { @@ -118,27 +115,34 @@ fn parse_session(path: &Path) -> Option { created_at.get_or_insert(ts); } } - continue; } + } - if value.get("type").and_then(Value::as_str) != Some("response_item") { - continue; - } + // Extract last_active_at and summary from tail lines (reverse order) + let mut last_active_at: Option = None; + let mut summary: Option = None; - let payload = match value.get("payload") { - Some(payload) => payload, - None => continue, + for line in tail.iter().rev() { + let value: Value = match serde_json::from_str(line) { + Ok(parsed) => parsed, + Err(_) => continue, }; - - if payload.get("type").and_then(Value::as_str) != Some("message") { - continue; + if last_active_at.is_none() { + last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } - - let text = payload.get("content").map(extract_text).unwrap_or_default(); - if text.trim().is_empty() { - continue; + if summary.is_none() && value.get("type").and_then(Value::as_str) == Some("response_item") { + if let Some(payload) = value.get("payload") { + if payload.get("type").and_then(Value::as_str) == Some("message") { + let text = payload.get("content").map(extract_text).unwrap_or_default(); + if !text.trim().is_empty() { + summary = Some(text); + } + } + } + } + if last_active_at.is_some() && summary.is_some() { + break; } - summary = Some(text); } let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); @@ -166,10 +170,7 @@ fn parse_session(path: &Path) -> Option { fn infer_session_id_from_filename(path: &Path) -> Option { let file_name = path.file_name()?.to_string_lossy(); - let re = - Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") - .ok()?; - re.find(&file_name).map(|mat| mat.as_str().to_string()) + UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string()) } fn collect_jsonl_files(root: &Path, files: &mut Vec) { diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index e8283bf0f..25b911514 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -7,7 +7,9 @@ use serde_json::Value; use crate::openclaw_config::get_openclaw_dir; use crate::session_manager::{SessionMessage, SessionMeta}; -use super::utils::{extract_text, parse_timestamp_to_ms, path_basename, truncate_summary}; +use super::utils::{ + extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, +}; const PROVIDER_ID: &str = "openclaw"; @@ -114,30 +116,22 @@ pub fn load_messages(path: &Path) -> Result, String> { } fn parse_session(path: &Path) -> Option { - let file = File::open(path).ok()?; - let reader = BufReader::new(file); + let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut cwd: Option = None; let mut created_at: Option = None; - let mut last_active_at: Option = None; let mut summary: Option = None; - for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; - let value: Value = match serde_json::from_str(&line) { + // Extract metadata and first message summary from head lines + for line in &head { + let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; - if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { - if created_at.is_none() { - created_at = Some(ts); - } - last_active_at = Some(ts); + if created_at.is_none() { + created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } let event_type = value.get("type").and_then(Value::as_str).unwrap_or(""); @@ -161,25 +155,28 @@ fn parse_session(path: &Path) -> Option { continue; } - if event_type != "message" { - continue; + // OpenClaw summary is the first message content + if event_type == "message" && summary.is_none() { + if let Some(message) = value.get("message") { + let text = message.get("content").map(extract_text).unwrap_or_default(); + if !text.trim().is_empty() { + summary = Some(text); + } + } } + } - // Extract first message content for summary - if summary.is_some() { - continue; - } - - let message = match value.get("message") { - Some(msg) => msg, - None => continue, + // Extract last_active_at from tail lines (reverse order) + let mut last_active_at: Option = None; + for line in tail.iter().rev() { + let value: Value = match serde_json::from_str(line) { + Ok(parsed) => parsed, + Err(_) => continue, }; - - let text = message.get("content").map(extract_text).unwrap_or_default(); - if text.trim().is_empty() { - continue; + if let Some(ts) = value.get("timestamp").and_then(parse_timestamp_to_ms) { + last_active_at = Some(ts); + break; } - summary = Some(text); } // Fall back to filename as session ID diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs index 4428bc57f..7cb5d115b 100644 --- a/src-tauri/src/session_manager/providers/opencode.rs +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -136,6 +136,7 @@ fn parse_session(storage: &Path, path: &Path) -> Option { .and_then(parse_timestamp_to_ms); // Derive title from directory basename if no explicit title + let has_title = title.is_some(); let display_title = title.or_else(|| { directory .as_deref() @@ -147,8 +148,12 @@ fn parse_session(storage: &Path, path: &Path) -> Option { let msg_dir = storage.join("message").join(&session_id); let source_path = msg_dir.to_string_lossy().to_string(); - // Get summary from first user message - let summary = get_first_user_summary(storage, &session_id); + // Skip expensive I/O if title already available from session JSON + let summary = if has_title { + display_title.clone() + } else { + get_first_user_summary(storage, &session_id) + }; Some(SessionMeta { provider_id: PROVIDER_ID.to_string(), diff --git a/src-tauri/src/session_manager/providers/utils.rs b/src-tauri/src/session_manager/providers/utils.rs index 76408ea35..e22c5a5ee 100644 --- a/src-tauri/src/session_manager/providers/utils.rs +++ b/src-tauri/src/session_manager/providers/utils.rs @@ -1,6 +1,50 @@ +use std::fs::File; +use std::io::{self, BufRead, BufReader, Seek, SeekFrom}; +use std::path::Path; + use chrono::{DateTime, FixedOffset}; use serde_json::Value; +/// Read the first `head_n` lines and last `tail_n` lines from a file. +/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking. +pub fn read_head_tail_lines( + path: &Path, + head_n: usize, + tail_n: usize, +) -> io::Result<(Vec, Vec)> { + 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 = 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 = 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 = 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 = 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 { let raw = value.as_str()?; DateTime::parse_from_rfc3339(raw) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e076577cd..2ce7aadd7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Switch", - "version": "3.10.3", + "version": "3.11.0", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index daf1d7327..05acafa89 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -53,15 +53,12 @@ export function DeepLinkImportDialog() { const unlistenImport = listen( "deeplink-import", async (event) => { - console.log("Deep link import event received:", event.payload); - // If config is present, merge it to get the complete configuration if (event.payload.config || event.payload.configUrl) { try { const mergedRequest = await deeplinkApi.mergeDeeplinkConfig( event.payload, ); - console.log("Config merged successfully:", mergedRequest); setRequest(mergedRequest); } catch (error) { console.error("Failed to merge config:", error); diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index 672848134..bf7853235 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -192,6 +192,7 @@ export function EditProviderDialog({ onCancel={() => onOpenChange(false)} initialData={initialData} showButtons={false} + isCurrent={liveSettings !== null} /> ); diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 1394fa69f..90bd60991 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -24,7 +24,6 @@ interface ProviderActionsProps { isTesting?: boolean; isProxyTakeover?: boolean; isOmo?: boolean; - isLastOmo?: boolean; onSwitch: () => void; onEdit: () => void; onDuplicate: () => void; @@ -49,7 +48,6 @@ export function ProviderActions({ isTesting: _isTesting, // Hidden: stream check feature disabled isProxyTakeover = false, isOmo = false, - isLastOmo = false, onSwitch, onEdit, onDuplicate, @@ -192,11 +190,7 @@ export function ProviderActions({ const buttonState = getMainButtonState(); - const canDelete = isOmo - ? !(isLastOmo && isCurrent) - : isAdditiveMode - ? true - : !isCurrent; + const canDelete = isOmo || isAdditiveMode ? true : !isCurrent; return (
diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 6590cd14c..96d8b4607 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -28,9 +28,7 @@ interface ProviderCardProps { appId: AppId; isInConfig?: boolean; // OpenCode: 是否已添加到 opencode.json isOmo?: boolean; - isLastOmo?: boolean; isOmoSlim?: boolean; - isLastOmoSlim?: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; @@ -94,9 +92,7 @@ export function ProviderCard({ appId, isInConfig = true, isOmo = false, - isLastOmo = false, isOmoSlim = false, - isLastOmoSlim = false, onSwitch, onEdit, onDelete, @@ -125,7 +121,6 @@ export function ProviderCard({ // OMO and OMO Slim share the same card behavior const isAnyOmo = isOmo || isOmoSlim; - const isLastAnyOmo = isOmo ? isLastOmo : isLastOmoSlim; const handleDisableAnyOmo = isOmoSlim ? onDisableOmoSlim : onDisableOmo; const { data: health } = useProviderHealth(provider.id, appId); @@ -390,7 +385,6 @@ export function ProviderCard({ isTesting={isTesting} isProxyTakeover={isProxyTakeover} isOmo={isAnyOmo} - isLastOmo={isLastAnyOmo} onSwitch={() => onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 928bdbcf4..259120142 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -36,9 +36,7 @@ import { } from "@/lib/query/failover"; import { useCurrentOmoProviderId, - useOmoProviderCount, useCurrentOmoSlimProviderId, - useOmoSlimProviderCount, } from "@/lib/query/omo"; import { useCallback } from "react"; import { Input } from "@/components/ui/input"; @@ -142,9 +140,7 @@ export function ProviderList({ const isOpenCode = appId === "opencode"; const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode); - const { data: omoProviderCount } = useOmoProviderCount(isOpenCode); const { data: currentOmoSlimId } = useCurrentOmoSlimProviderId(isOpenCode); - const { data: omoSlimProviderCount } = useOmoSlimProviderCount(isOpenCode); const getFailoverPriority = useCallback( (providerId: string): number | undefined => { @@ -293,15 +289,7 @@ export function ProviderList({ appId={appId} isInConfig={isProviderInConfig(provider.id)} isOmo={isOmo} - isLastOmo={ - isOmo && (omoProviderCount ?? 0) <= 1 && isOmoCurrent - } isOmoSlim={isOmoSlim} - isLastOmoSlim={ - isOmoSlim && - (omoSlimProviderCount ?? 0) <= 1 && - isOmoSlimCurrent - } onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} @@ -420,9 +408,7 @@ interface SortableProviderCardProps { appId: AppId; isInConfig: boolean; isOmo: boolean; - isLastOmo: boolean; isOmoSlim: boolean; - isLastOmoSlim: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; @@ -453,9 +439,7 @@ function SortableProviderCard({ appId, isInConfig, isOmo, - isLastOmo, isOmoSlim, - isLastOmoSlim, onSwitch, onEdit, onDelete, @@ -500,9 +484,7 @@ function SortableProviderCard({ appId={appId} isInConfig={isInConfig} isOmo={isOmo} - isLastOmo={isLastOmo} isOmoSlim={isOmoSlim} - isLastOmoSlim={isLastOmoSlim} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} diff --git a/src/components/providers/forms/OmoCommonConfigEditor.tsx b/src/components/providers/forms/OmoCommonConfigEditor.tsx deleted file mode 100644 index 9f0a4a3d9..000000000 --- a/src/components/providers/forms/OmoCommonConfigEditor.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { useEffect, useState } from "react"; -import { FullScreenPanel } from "@/components/common/FullScreenPanel"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Save, FolderInput, Loader2 } from "lucide-react"; -import JsonEditor from "@/components/JsonEditor"; -import { - OmoGlobalConfigFields, - type OmoGlobalConfigFieldsRef, -} from "./OmoGlobalConfigFields"; -import type { OmoGlobalConfig } from "@/types/omo"; - -interface OmoCommonConfigEditorProps { - previewValue: string; - useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; - isModalOpen: boolean; - onEditClick: () => void; - onModalClose: () => void; - onSave: () => Promise; - isSaving: boolean; - onGlobalConfigStateChange: (config: OmoGlobalConfig) => void; - globalConfigRef: React.RefObject; - fieldsKey: number; - isSlim?: boolean; -} - -export function OmoCommonConfigEditor({ - previewValue, - useCommonConfig, - onCommonConfigToggle, - isModalOpen, - onEditClick, - onModalClose, - onSave, - isSaving, - onGlobalConfigStateChange, - globalConfigRef, - fieldsKey, - isSlim = false, -}: OmoCommonConfigEditorProps) { - const { t } = useTranslation(); - const [isDarkMode, setIsDarkMode] = useState(false); - const [isImporting, setIsImporting] = useState(false); - useEffect(() => { - const syncDarkMode = () => - setIsDarkMode(document.documentElement.classList.contains("dark")); - syncDarkMode(); - const observer = new MutationObserver(syncDarkMode); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); - return () => observer.disconnect(); - }, []); - const handleImportLocal = async () => { - if (!globalConfigRef.current) return; - setIsImporting(true); - try { - await globalConfigRef.current.importFromLocal(); - } finally { - setIsImporting(false); - } - }; - return ( - <> -
-
- -
- -
-
-
- -
- {}} - darkMode={isDarkMode} - rows={14} - showValidation={false} - language="json" - /> -
- - - - - - } - > -
-

- {t("omo.commonConfigHint", { - defaultValue: - "OMO common config will be merged into all OMO configs that enable it", - })} -

- } - onStateChange={onGlobalConfigStateChange} - hideSaveButtons - isSlim={isSlim} - /> -
-
- - ); -} diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index d0ffa02d2..52cced9cb 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -722,14 +722,23 @@ export function OmoFormFields({ return; } + let filledCount = 0; + let alreadySetCount = 0; + let unmatchedCount = 0; + const updatedAgents = { ...agents }; for (const agentDef of builtinAgentDefs) { const recommendedValue = resolveRecommendedModel(agentDef.recommended); - if (recommendedValue && !updatedAgents[agentDef.key]?.model) { + if (!recommendedValue) { + unmatchedCount++; + } else if (updatedAgents[agentDef.key]?.model) { + alreadySetCount++; + } else { updatedAgents[agentDef.key] = { ...updatedAgents[agentDef.key], model: recommendedValue, }; + filledCount++; } } onAgentsChange(updatedAgents); @@ -738,15 +747,50 @@ export function OmoFormFields({ const updatedCategories = { ...categories }; for (const catDef of OMO_BUILTIN_CATEGORIES) { const recommendedValue = resolveRecommendedModel(catDef.recommended); - if (recommendedValue && !updatedCategories[catDef.key]?.model) { + if (!recommendedValue) { + unmatchedCount++; + } else if (updatedCategories[catDef.key]?.model) { + alreadySetCount++; + } else { updatedCategories[catDef.key] = { ...updatedCategories[catDef.key], model: recommendedValue, }; + filledCount++; } } onCategoriesChange(updatedCategories); } + + if (filledCount > 0 && unmatchedCount === 0) { + toast.success( + t("omo.fillRecommendedSuccess", { + defaultValue: "Filled {{count}} recommended models", + count: filledCount, + }), + ); + } else if (filledCount > 0 && unmatchedCount > 0) { + toast.success( + t("omo.fillRecommendedPartial", { + defaultValue: + "Filled {{filled}} recommended models, {{unmatched}} unmatched", + filled: filledCount, + unmatched: unmatchedCount, + }), + ); + } else if (alreadySetCount > 0 && unmatchedCount === 0) { + toast.info( + t("omo.fillRecommendedAllSet", { + defaultValue: "All slots already have models configured", + }), + ); + } else { + toast.warning( + t("omo.fillRecommendedNoMatch", { + defaultValue: "Recommended models not found in configured providers", + }), + ); + } }; const configuredAgentCount = Object.keys(agents).length; diff --git a/src/components/providers/forms/OmoGlobalConfigFields.tsx b/src/components/providers/forms/OmoGlobalConfigFields.tsx deleted file mode 100644 index 3e8e1ab9d..000000000 --- a/src/components/providers/forms/OmoGlobalConfigFields.tsx +++ /dev/null @@ -1,773 +0,0 @@ -import { - useState, - useEffect, - useCallback, - forwardRef, - useImperativeHandle, -} from "react"; -import { useTranslation } from "react-i18next"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Save, - Loader2, - X, - FolderInput, - RotateCcw, - ChevronsUpDown, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { toast } from "sonner"; -import type { OmoGlobalConfig } from "@/types/omo"; -import { - OMO_DISABLEABLE_AGENTS, - OMO_DISABLEABLE_MCPS, - OMO_DISABLEABLE_HOOKS, - OMO_DISABLEABLE_SKILLS, - OMO_DEFAULT_SCHEMA_URL, - OMO_SISYPHUS_AGENT_PLACEHOLDER, - OMO_LSP_PLACEHOLDER, - OMO_EXPERIMENTAL_PLACEHOLDER, - OMO_BACKGROUND_TASK_PLACEHOLDER, - OMO_BROWSER_AUTOMATION_PLACEHOLDER, - OMO_CLAUDE_CODE_PLACEHOLDER, - OMO_SLIM_DISABLEABLE_AGENTS, - OMO_SLIM_DISABLEABLE_MCPS, - OMO_SLIM_DISABLEABLE_HOOKS, - OMO_SLIM_DEFAULT_SCHEMA_URL, -} from "@/types/omo"; -import { - useOmoGlobalConfig, - useSaveOmoGlobalConfig, - useReadOmoLocalFile, - useOmoSlimGlobalConfig, - useSaveOmoSlimGlobalConfig, - useReadOmoSlimLocalFile, -} from "@/lib/query/omo"; - -interface PresetOption { - readonly value: string; - readonly label: string; -} - -export interface OmoGlobalConfigFieldsRef { - buildCurrentConfig: () => OmoGlobalConfig; - buildCurrentConfigStrict: () => OmoGlobalConfig; - importFromLocal: () => Promise; -} - -interface OmoGlobalConfigFieldsProps { - onStateChange?: (config: OmoGlobalConfig) => void; - hideSaveButtons?: boolean; - isSlim?: boolean; -} - -type OmoAdvancedFieldKey = - | "lspStr" - | "experimentalStr" - | "backgroundTaskStr" - | "browserStr" - | "claudeCodeStr"; - -const OMO_ADVANCED_JSON_FIELDS: ReadonlyArray<{ - key: OmoAdvancedFieldKey; - labelKey: string; - defaultLabel: string; - placeholder: string; - minHeight: string; -}> = [ - { - key: "lspStr", - labelKey: "omo.advancedLsp", - defaultLabel: "LSP Config", - placeholder: OMO_LSP_PLACEHOLDER, - minHeight: "200px", - }, - { - key: "experimentalStr", - labelKey: "omo.advancedExperimental", - defaultLabel: "Experimental Features", - placeholder: OMO_EXPERIMENTAL_PLACEHOLDER, - minHeight: "120px", - }, - { - key: "backgroundTaskStr", - labelKey: "omo.advancedBackgroundTask", - defaultLabel: "Background Tasks", - placeholder: OMO_BACKGROUND_TASK_PLACEHOLDER, - minHeight: "250px", - }, - { - key: "browserStr", - labelKey: "omo.advancedBrowserAutomation", - defaultLabel: "Browser Automation", - placeholder: OMO_BROWSER_AUTOMATION_PLACEHOLDER, - minHeight: "80px", - }, - { - key: "claudeCodeStr", - labelKey: "omo.advancedClaudeCode", - defaultLabel: "Claude Code", - placeholder: OMO_CLAUDE_CODE_PLACEHOLDER, - minHeight: "180px", - }, -]; - -const OMO_SLIM_ADVANCED_KEYS: ReadonlySet = new Set([ - "lspStr", - "experimentalStr", -]); - -function TagListEditor({ - label, - values, - onChange, - placeholder, - presets, -}: { - label: string; - values: string[]; - onChange: (values: string[]) => void; - placeholder?: string; - presets?: readonly PresetOption[]; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - - const toggleValue = (v: string) => { - if (values.includes(v)) { - onChange(values.filter((x) => x !== v)); - } else { - onChange([...values, v]); - } - }; - const customValue = search.trim(); - const canAddCustom = customValue.length > 0 && !values.includes(customValue); - const triggerText = - values.length === 0 - ? placeholder || t("omo.selectPlaceholder", { defaultValue: "Select..." }) - : values.length === 1 - ? values[0] - : `${values[0]} +${values.length - 1}`; - - const availablePresets = presets?.filter( - (p) => - !search.trim() || - p.label.toLowerCase().includes(search.toLowerCase()) || - p.value.toLowerCase().includes(search.toLowerCase()), - ); - - return ( -
-
- - {values.length > 0 && ( - - )} -
- {values.length > 0 && ( -
- {values.map((v, i) => ( - - {v} - - - ))} -
- )} - - - - - -
- 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 - /> -
- {canAddCustom && ( - - )} -
- {availablePresets && availablePresets.length > 0 ? ( - availablePresets.map((p) => { - const checked = values.includes(p.value); - return ( - e.preventDefault()} - onCheckedChange={() => toggleValue(p.value)} - className="text-sm" - > - {p.label} - - ); - }) - ) : ( -
- {t("omo.noMatches", { defaultValue: "No matches" })} -
- )} -
-
-
-
- ); -} - -function JsonTextareaField({ - label, - value, - onChange, - placeholder, - minHeight, -}: { - label: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; - minHeight?: string; -}) { - return ( -
- -