mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3faa0164ab | |||
| 4eb983c58f | |||
| cef3812745 | |||
| 6c9a7ef949 | |||
| 48c434a20a | |||
| 4f6dfff179 | |||
| 4b9cca12d3 | |||
| 8db44a78b2 | |||
| efad0c0f91 | |||
| b536bd0366 | |||
| 783bc60329 | |||
| 924e5386f9 | |||
| 74f67bc1ee | |||
| ae5d05b08c | |||
| 4edb08cd53 | |||
| a8ea99c3fe | |||
| 22c0e7bb5c | |||
| 8af31c3e61 |
@@ -9,97 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [3.10.2] - 2026-01-24
|
||||
|
||||
### Patch Release
|
||||
|
||||
This maintenance release adds skill sync options and includes important bug fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Skills**: Add skill sync method setting with symlink/copy options
|
||||
- **Partners**: Add RightCode as official partner
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prompts**: Clear prompt file when all prompts are disabled
|
||||
- **OpenCode**: Preserve extra model fields during serialization
|
||||
- **Provider Form**: Backfill model fields when editing Claude provider
|
||||
|
||||
---
|
||||
|
||||
## [3.10.1] - 2026-01-23
|
||||
|
||||
### Patch Release
|
||||
|
||||
This maintenance release includes important bug fixes for Windows platform, UI improvements, and code quality enhancements.
|
||||
|
||||
### Added
|
||||
|
||||
- **Provider Icons**: Updated RightCode provider icon with improved visual design
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy Rectifier**: Changed rectifier default state to disabled for better stability
|
||||
- **Window Settings**: Reordered window settings and updated default values for improved UX
|
||||
- **UI Layout**: Increased app icon collapse threshold from 3 to 4 icons
|
||||
- **Code Quality**: Simplified `RectifierConfig` implementation using `#[derive(Default)]`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows Platform**:
|
||||
- Fixed terminal window closing immediately after execution on Windows
|
||||
- Corrected OpenCode config path resolution on Windows
|
||||
- **UI Improvements**:
|
||||
- Fixed ProviderIcon color validation to prevent black icons from appearing
|
||||
- Unified layout padding across all panels for consistent spacing
|
||||
- Fixed panel content alignment with header constraints
|
||||
- **Code Quality**: Resolved Rust Clippy warnings and applied consistent formatting
|
||||
|
||||
---
|
||||
|
||||
## [3.10.0] - 2026-01-21
|
||||
|
||||
### Feature Release
|
||||
|
||||
This release introduces OpenCode support and brings improvements across proxy, usage tracking, and overall UX.
|
||||
|
||||
### Added
|
||||
|
||||
- **OpenCode Support** - Manage OpenCode providers, MCP servers, and Skills, with first-launch import and full internationalization (#695)
|
||||
- **Global Proxy** - Add global proxy settings for outbound network requests (#596)
|
||||
- **Claude Rectifier** - Add thinking signature rectifier for Claude API (#595)
|
||||
- **Health Check Enhancements** - Configurable prompt and CLI-compatible requests for stream health check (#623)
|
||||
- **Per-Provider Config** - Support provider-specific configuration and persistence (#663)
|
||||
- **App Visibility Controls** - Show/hide apps and keep tray menu in sync (Gemini hidden by default)
|
||||
- **Takeover Compact Mode** - Use a compact AppSwitcher layout when showing 3+ visible apps
|
||||
- **Keyboard Shortcut** - Press `ESC` to quickly go back/close panels (#670)
|
||||
- **Terminal Improvements** - Provider-specific terminal button, `fnm` path support, and safer cross-platform launching (#564)
|
||||
- **WSL Tool Detection** - Detect tool versions in WSL with additional security hardening (#627)
|
||||
- **Skills Presets** - Add `baoyu-skills` preset repo and auto-supplement missing default repos
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy Logging** - Simplify proxy log output (#585)
|
||||
- **Pricing Editor UX** - Unify pricing edit modal with `FullScreenPanel`
|
||||
- **Advanced Settings Layout** - Move rectifier section below failover for better flow
|
||||
- **OpenRouter Compat Mode** - Disable OpenRouter compatibility mode by default and hide UI toggle
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auto Failover** - Switch to P1 immediately when enabling auto failover
|
||||
- **Provider Edit Dialog** - Fix stale data when reopening provider editor after save (#654)
|
||||
- **Deeplink** - Support multiple endpoints and prioritize `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL` (#597)
|
||||
- **MCP (WSL)** - Skip `cmd /c` wrapper for WSL target paths (#592)
|
||||
- **Usage Templates** - Add variable hints and validation fixes; prevent config leaking between providers (#628)
|
||||
- **Gemini Timeout Format** - Convert timeout params to Gemini CLI format (#580)
|
||||
- **UI** - Fix Select dropdown rendering in `FullScreenPanel`; auto-apply default icon color when unset
|
||||
- **Usage UI** - Auto-adapt usage block offset based on action buttons width (#613)
|
||||
- **Provider Endpoint** - Persist endpoint auto-select state (#611)
|
||||
- **Provider Form** - Reset baseUrl and apiKey states when switching presets
|
||||
|
||||
---
|
||||
|
||||
## [3.9.1] - 2026-01-09
|
||||
|
||||
### Bug Fix Release
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
|
||||
## Features
|
||||
|
||||
### Current Version: v3.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
### Current Version: v3.9.1 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## 特長
|
||||
|
||||
### 現在のバージョン:v3.10.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
### 現在のバージョン:v3.9.1 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||
|
||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 当前版本:v3.10.2 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
### 当前版本:v3.9.1 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||
|
||||
**v3.8.0 重大更新(2025-11-28)**
|
||||
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
# CC Switch v3.10.0
|
||||
|
||||
> OpenCode Support, Global Proxy, Claude Rectifier & Multi-App Experience Enhancements
|
||||
|
||||
**[中文版 →](release-note-v3.10.0-zh.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.10.0 introduces OpenCode support, becoming the fourth managed CLI application.
|
||||
This release also brings global proxy settings, Claude Rectifier (thinking signature fixer), enhanced health checks, per-provider configuration, and many other important features, along with comprehensive improvements to multi-app workflows and terminal experience.
|
||||
|
||||
**Release Date**: 2026-01-21
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- OpenCode Support: Full management of providers, MCP servers, and Skills with auto-import on first launch
|
||||
- Global Proxy: Configure a unified proxy for all outbound network requests
|
||||
- Claude Rectifier: Thinking signature fixer for better compatibility with third-party APIs
|
||||
- Enhanced Health Checks: Configurable prompts and CLI-compatible request format
|
||||
- Per-Provider Config: Persistent provider-specific configuration support
|
||||
- App Visibility Control: Freely show/hide apps with synchronized tray menu updates
|
||||
- Terminal Improvements: Provider-specific terminal buttons, fnm path support, cross-platform safe launch
|
||||
- WSL Tool Detection: Detect tool versions in WSL environment with security hardening
|
||||
|
||||
---
|
||||
|
||||
## Main Features
|
||||
|
||||
### OpenCode Support (New Fourth App)
|
||||
|
||||
- Complete OpenCode provider management: add, edit, switch, delete
|
||||
- MCP server management: unified architecture with Claude/Codex/Gemini
|
||||
- Skills support: OpenCode can also use Skills functionality
|
||||
- Auto-import on first launch: automatically imports existing OpenCode configuration when detected
|
||||
- Full internationalization: Chinese/English/Japanese support (#695)
|
||||
|
||||
### Global Proxy
|
||||
|
||||
- Configure a unified proxy for all outbound network requests (#596, thanks @yovinchen)
|
||||
- Supports HTTP/HTTPS proxy protocols
|
||||
- Suitable for network environments requiring proxy access to external APIs
|
||||
|
||||
### Claude Rectifier (Thinking Signature Fixer)
|
||||
|
||||
- Automatically fixes Claude API thinking signatures (#595, thanks @yovinchen)
|
||||
- Resolves incompatible thinking block formats returned by some third-party API gateways
|
||||
- Can be enabled/disabled in Advanced Settings
|
||||
|
||||
### Enhanced Health Checks
|
||||
|
||||
- Configurable custom prompts for streaming health checks (#623, thanks @yovinchen)
|
||||
- Supports CLI-compatible request format for better simulation of real usage scenarios
|
||||
- Improves fault detection accuracy
|
||||
|
||||
### Per-Provider Config
|
||||
|
||||
- Support for saving configuration separately for each provider (#663, thanks @yovinchen)
|
||||
- Persistent configuration: provider-specific settings retained after restart
|
||||
- Suitable for scenarios where different providers require different configurations
|
||||
|
||||
### App Visibility Control
|
||||
|
||||
- Freely show/hide any app (Gemini hidden by default)
|
||||
- Tray menu automatically syncs visibility settings
|
||||
- Hidden apps won't appear in the main interface or tray menu
|
||||
|
||||
### Takeover Compact Mode
|
||||
|
||||
- Automatically uses compact layout when 3 or more visible apps are displayed
|
||||
- Optimizes space utilization in multi-app scenarios
|
||||
|
||||
### Terminal Improvements
|
||||
|
||||
- Provider-specific terminal button: one-click to use current provider in terminal (#564, thanks @kkkman22)
|
||||
- `fnm` path support: automatically recognizes Node.js paths managed by fnm
|
||||
- Cross-platform safe launch: improved terminal launch logic for Windows/macOS/Linux
|
||||
|
||||
### WSL Tool Detection
|
||||
|
||||
- Detect tool versions in WSL environment (#627, thanks @yovinchen)
|
||||
- Added security hardening to prevent command injection risks
|
||||
|
||||
### Skills Preset Enhancements
|
||||
|
||||
- Added `baoyu-skills` preset repository
|
||||
- Automatically supplements missing default repositories for out-of-the-box experience
|
||||
|
||||
---
|
||||
|
||||
## Experience Improvements
|
||||
|
||||
- Keyboard shortcuts: Press `ESC` to quickly return/close panels (#670, thanks @xxk8)
|
||||
- Simplified proxy logs: cleaner and more readable output (#585, thanks @yovinchen)
|
||||
- Pricing editor UX: unified `FullScreenPanel` style
|
||||
- Advanced settings layout: Rectifier section moved below Failover for better logical flow
|
||||
- OpenRouter compatibility mode: disabled by default, UI toggle hidden (reduces clutter)
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Proxy & Failover
|
||||
|
||||
- Immediately switch to P1 when auto-failover is enabled (instead of waiting for next request)
|
||||
|
||||
### Provider Management
|
||||
|
||||
- Fixed stale data when reopening provider edit dialog after save (#654, thanks @YangYongAn)
|
||||
- Fixed baseUrl and apiKey state not resetting when switching presets
|
||||
- Fixed endpoint auto-selection state not persisting (#611, thanks @yovinchen)
|
||||
- Automatically apply default color when icon color is not set
|
||||
|
||||
### Deep Links
|
||||
|
||||
- Support multi-endpoint import (#597, thanks @yovinchen)
|
||||
- Prefer `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL`
|
||||
|
||||
### MCP
|
||||
|
||||
- Skip `cmd /c` wrapper for WSL target paths (#592, thanks @cxyfer)
|
||||
|
||||
### Usage Templates
|
||||
|
||||
- Added variable hints, fixed validation issues (#628, thanks @YangYongAn)
|
||||
- Prevent configuration leakage between providers
|
||||
- Usage block offset automatically adapts to action button width (#613, thanks @yovinchen)
|
||||
|
||||
### Gemini
|
||||
|
||||
- Convert timeout parameters to Gemini CLI format (#580, thanks @cxyfer)
|
||||
|
||||
### UI
|
||||
|
||||
- Fixed Select dropdown rendering issues in `FullScreenPanel`
|
||||
|
||||
---
|
||||
|
||||
## Notes & Considerations
|
||||
|
||||
- **OpenCode is a newly supported app**: OpenCode CLI must be installed first to use related features.
|
||||
- **Global proxy affects all outbound requests**: including usage queries, health checks, and other network operations.
|
||||
- **Rectifier is experimental**: can be disabled in Advanced Settings if issues occur.
|
||||
|
||||
---
|
||||
|
||||
## Special Thanks
|
||||
|
||||
Thanks to @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 for their contributions to this release!
|
||||
Thanks to @libukai for designing the elegant failover-related UI!
|
||||
|
||||
---
|
||||
|
||||
## 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.10.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.10.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ------------------------------------------------------------------ |
|
||||
| `CC-Switch-v3.10.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.10.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and it will open normally afterwards.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation Method |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
|
||||
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -1,206 +0,0 @@
|
||||
# CC Switch v3.10.0
|
||||
|
||||
> OpenCode サポート、グローバルプロキシ、Claude Rectifier とマルチアプリ体験の強化
|
||||
|
||||
**[中文版 →](release-note-v3.10.0-zh.md) | [English →](release-note-v3.10.0-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.10.0 では OpenCode サポートが追加され、4番目の管理対象 CLI アプリケーションとなりました。
|
||||
また、グローバルプロキシ設定、Claude Rectifier(thinking 署名修正機能)、ヘルスチェックの強化、プロバイダー別設定など、多くの重要な機能が追加され、マルチアプリワークフローとターミナル体験が全面的に改善されました。
|
||||
|
||||
**リリース日**: 2026-01-21
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- OpenCode サポート:プロバイダー、MCP サーバー、Skills の完全管理、初回起動時の自動インポート
|
||||
- グローバルプロキシ:すべての送信ネットワークリクエストに統一プロキシを設定
|
||||
- Claude Rectifier:thinking 署名修正機能、サードパーティ API との互換性向上
|
||||
- ヘルスチェック強化:カスタムプロンプト設定、CLI 互換リクエスト形式
|
||||
- プロバイダー別設定:プロバイダー固有の設定の永続化をサポート
|
||||
- アプリ表示制御:アプリの表示/非表示を自由に設定、トレイメニューと同期
|
||||
- ターミナル改善:プロバイダー専用ターミナルボタン、fnm パスサポート、クロスプラットフォーム安全起動
|
||||
- WSL ツール検出:WSL 環境でのツールバージョン検出とセキュリティ強化
|
||||
|
||||
---
|
||||
|
||||
## 主な機能
|
||||
|
||||
### OpenCode サポート(新しい4番目のアプリ)
|
||||
|
||||
- 完全な OpenCode プロバイダー管理:追加、編集、切り替え、削除
|
||||
- MCP サーバー管理:Claude/Codex/Gemini と統一されたアーキテクチャ
|
||||
- Skills サポート:OpenCode でも Skills 機能を使用可能
|
||||
- 初回起動時の自動インポート:既存の OpenCode 設定を検出すると自動的にインポート
|
||||
- 完全な国際化:中国語/英語/日本語サポート (#695)
|
||||
|
||||
### グローバルプロキシ
|
||||
|
||||
- すべての送信ネットワークリクエストに統一プロキシを設定 (#596、@yovinchen に感謝)
|
||||
- HTTP/HTTPS プロキシプロトコルをサポート
|
||||
- 外部 API へのプロキシアクセスが必要なネットワーク環境に適用
|
||||
|
||||
### Claude Rectifier(Thinking 署名修正機能)
|
||||
|
||||
- Claude API の thinking 署名を自動修正 (#595、@yovinchen に感謝)
|
||||
- 一部のサードパーティ API ゲートウェイが返す互換性のない thinking ブロック形式を解決
|
||||
- 詳細設定で有効/無効を切り替え可能
|
||||
|
||||
### ヘルスチェック強化
|
||||
|
||||
- ストリーミングヘルスチェック用のカスタムプロンプトを設定可能 (#623、@yovinchen に感謝)
|
||||
- CLI 互換リクエスト形式をサポートし、実際の使用シナリオをより良くシミュレート
|
||||
- 障害検出の精度を向上
|
||||
|
||||
### プロバイダー別設定
|
||||
|
||||
- 各プロバイダーごとに設定を個別に保存可能 (#663、@yovinchen に感謝)
|
||||
- 設定の永続化:再起動後もプロバイダー固有の設定を保持
|
||||
- 異なるプロバイダーに異なる設定が必要なシナリオに適用
|
||||
|
||||
### アプリ表示制御
|
||||
|
||||
- 任意のアプリを自由に表示/非表示(Gemini はデフォルトで非表示)
|
||||
- トレイメニューは表示設定と自動的に同期
|
||||
- 非表示のアプリはメインインターフェースとトレイメニューに表示されない
|
||||
|
||||
### Takeover コンパクトモード
|
||||
|
||||
- 3つ以上の表示アプリがある場合、自動的にコンパクトレイアウトを使用
|
||||
- マルチアプリシナリオでのスペース利用を最適化
|
||||
|
||||
### ターミナル改善
|
||||
|
||||
- プロバイダー専用ターミナルボタン:ワンクリックでターミナルで現在のプロバイダーを使用 (#564、@kkkman22 に感謝)
|
||||
- `fnm` パスサポート:fnm で管理された Node.js パスを自動認識
|
||||
- クロスプラットフォーム安全起動:Windows/macOS/Linux のターミナル起動ロジックを改善
|
||||
|
||||
### WSL ツール検出
|
||||
|
||||
- WSL 環境でツールバージョンを検出 (#627、@yovinchen に感謝)
|
||||
- コマンドインジェクションリスクを防ぐためのセキュリティ強化を追加
|
||||
|
||||
### Skills プリセット強化
|
||||
|
||||
- `baoyu-skills` プリセットリポジトリを追加
|
||||
- 不足しているデフォルトリポジトリを自動補完し、すぐに使える状態を確保
|
||||
|
||||
---
|
||||
|
||||
## 体験の改善
|
||||
|
||||
- キーボードショートカット:`ESC` を押してパネルをすばやく戻る/閉じる (#670、@xxk8 に感謝)
|
||||
- プロキシログの簡素化:より明確で読みやすい出力 (#585、@yovinchen に感謝)
|
||||
- 価格エディター UX:統一された `FullScreenPanel` スタイル
|
||||
- 詳細設定レイアウト:Rectifier セクションを Failover の下に移動し、論理的な流れを改善
|
||||
- OpenRouter 互換モード:デフォルトで無効、UI トグルを非表示(煩雑さを軽減)
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### プロキシとフェイルオーバー
|
||||
|
||||
- 自動フェイルオーバーが有効な場合、すぐに P1 に切り替え(次のリクエストを待たずに)
|
||||
|
||||
### プロバイダー管理
|
||||
|
||||
- 保存後にプロバイダー編集ダイアログを再度開いたときにデータが古い問題を修正 (#654、@YangYongAn に感謝)
|
||||
- プリセット切り替え時に baseUrl と apiKey の状態がリセットされない問題を修正
|
||||
- エンドポイント自動選択状態が永続化されない問題を修正 (#611、@yovinchen に感謝)
|
||||
- アイコンカラーが設定されていない場合、デフォルトカラーを自動適用
|
||||
|
||||
### ディープリンク
|
||||
|
||||
- マルチエンドポイントインポートをサポート (#597、@yovinchen に感謝)
|
||||
- `GEMINI_BASE_URL` より `GOOGLE_GEMINI_BASE_URL` を優先
|
||||
|
||||
### MCP
|
||||
|
||||
- WSL ターゲットパスの `cmd /c` ラッパーをスキップ (#592、@cxyfer に感謝)
|
||||
|
||||
### 使用量テンプレート
|
||||
|
||||
- 変数ヒントを追加、検証の問題を修正 (#628、@YangYongAn に感謝)
|
||||
- プロバイダー間での設定漏洩を防止
|
||||
- 使用量ブロックのオフセットがアクションボタンの幅に自動適応 (#613、@yovinchen に感謝)
|
||||
|
||||
### Gemini
|
||||
|
||||
- タイムアウトパラメータを Gemini CLI 形式に変換 (#580、@cxyfer に感謝)
|
||||
|
||||
### UI
|
||||
|
||||
- `FullScreenPanel` での Select ドロップダウンのレンダリング問題を修正
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
- **OpenCode は新しくサポートされたアプリです**:関連機能を使用するには、まず OpenCode CLI をインストールする必要があります。
|
||||
- **グローバルプロキシはすべての送信リクエストに影響します**:使用量クエリ、ヘルスチェックなどのネットワーク操作を含みます。
|
||||
- **Rectifier は実験的機能です**:問題が発生した場合は、詳細設定で無効にできます。
|
||||
|
||||
---
|
||||
|
||||
## 特別な感謝
|
||||
|
||||
@yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 の皆様、このリリースへの貢献に感謝します!
|
||||
@libukai 様、エレガントなフェイルオーバー関連 UI のデザインに感謝します!
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[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.10.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.10.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.10.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.10.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> **注意**:作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
|
||||
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -1,206 +0,0 @@
|
||||
# CC Switch v3.10.0
|
||||
|
||||
> OpenCode 支持、全局代理、Claude Rectifier 与多应用体验增强
|
||||
|
||||
**[English →](release-note-v3.10.0-en.md) | [日本語版 →](release-note-v3.10.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.10.0 新增 OpenCode 支持,成为第四个受管理的 CLI 应用。
|
||||
同时带来全局代理设置、Claude Rectifier(thinking 签名修正器)、健康检查增强、按供应商配置等多项重要功能,并对多应用工作流与终端体验做了全面改进。
|
||||
|
||||
**发布日期**:2026-01-21
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- OpenCode 支持:供应商、MCP 服务器、Skills 全面管理,首次启动自动导入
|
||||
- 全局代理:为出站网络请求统一配置代理
|
||||
- Claude Rectifier:thinking 签名修正器,兼容更多第三方 API
|
||||
- 健康检查增强:可配置提示词、CLI 兼容请求
|
||||
- 按供应商配置:支持供应商特定配置的持久化
|
||||
- 应用可见性控制:自由显示/隐藏应用,托盘菜单同步更新
|
||||
- 终端改进:供应商专属终端按钮、fnm 路径支持、跨平台安全启动
|
||||
- WSL 工具检测:在 WSL 环境检测工具版本,并增加安全加固
|
||||
|
||||
---
|
||||
|
||||
## 主要功能
|
||||
|
||||
### OpenCode 支持(新增第四应用)
|
||||
|
||||
- 完整的 OpenCode 供应商管理:新增、编辑、切换、删除
|
||||
- MCP 服务器管理:与 Claude/Codex/Gemini 统一架构
|
||||
- Skills 支持:OpenCode 也可使用 Skills 功能
|
||||
- 首次启动自动导入:检测到已有 OpenCode 配置时自动导入
|
||||
- 完整国际化:中/英/日三语支持(#695)
|
||||
|
||||
### 全局代理(Global Proxy)
|
||||
|
||||
- 为所有出站网络请求配置统一代理(#596,感谢 @yovinchen)
|
||||
- 支持 HTTP/HTTPS 代理协议
|
||||
- 适用于需要代理访问外部 API 的网络环境
|
||||
|
||||
### Claude Rectifier(Thinking 签名修正器)
|
||||
|
||||
- 自动修正 Claude API 的 thinking 签名(#595,感谢 @yovinchen)
|
||||
- 解决部分第三方 API 网关返回的 thinking 块格式不兼容问题
|
||||
- 在高级设置中可开启/关闭
|
||||
|
||||
### 健康检查增强
|
||||
|
||||
- 可配置自定义提示词(prompt)用于流式健康检查(#623,感谢 @yovinchen)
|
||||
- 支持 CLI 兼容请求格式,更好地模拟真实使用场景
|
||||
- 提升故障检测的准确性
|
||||
|
||||
### 按供应商配置(Per-Provider Config)
|
||||
|
||||
- 支持为每个供应商单独保存配置(#663,感谢 @yovinchen)
|
||||
- 配置持久化:重启后保留供应商专属设置
|
||||
- 适用于不同供应商需要不同配置的场景
|
||||
|
||||
### 应用可见性控制
|
||||
|
||||
- 自由显示/隐藏任意应用(Gemini 默认隐藏)
|
||||
- 托盘菜单自动同步可见性设置
|
||||
- 隐藏的应用不会出现在主界面和托盘菜单中
|
||||
|
||||
### Takeover Compact Mode
|
||||
|
||||
- 当显示 3 个及以上可见应用时,自动使用紧凑布局
|
||||
- 优化多应用场景下的空间利用
|
||||
|
||||
### 终端改进
|
||||
|
||||
- 供应商专属终端按钮:一键在终端中使用当前供应商(#564,感谢 @kkkman22)
|
||||
- `fnm` 路径支持:自动识别 fnm 管理的 Node.js 路径
|
||||
- 跨平台安全启动:改进 Windows/macOS/Linux 的终端启动逻辑
|
||||
|
||||
### WSL 工具检测
|
||||
|
||||
- 在 WSL 环境中检测工具版本(#627,感谢 @yovinchen)
|
||||
- 增加安全加固,防止命令注入风险
|
||||
|
||||
### Skills 预设增强
|
||||
|
||||
- 新增 `baoyu-skills` 预设仓库
|
||||
- 自动补充缺失的默认仓库,确保开箱即用
|
||||
|
||||
---
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 键盘快捷键:按 `ESC` 快速返回/关闭面板(#670,感谢 @xxk8)
|
||||
- 代理日志简化:输出更清晰易读(#585,感谢 @yovinchen)
|
||||
- 定价编辑器 UX:统一使用 `FullScreenPanel` 风格
|
||||
- 高级设置布局:Rectifier 区块移至 Failover 下方,逻辑更顺畅
|
||||
- OpenRouter 兼容模式:默认禁用,UI 开关隐藏(减少干扰)
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### 代理与故障切换
|
||||
|
||||
- 启用自动故障切换时立即切换到 P1(而非等待下次请求)
|
||||
|
||||
### 供应商管理
|
||||
|
||||
- 修复供应商编辑对话框保存后重新打开时数据过时的问题(#654,感谢 @YangYongAn)
|
||||
- 修复切换预设时 baseUrl 和 apiKey 状态未重置的问题
|
||||
- 修复端点自动选择状态未持久化的问题(#611,感谢 @yovinchen)
|
||||
- 未设置图标颜色时自动应用默认颜色
|
||||
|
||||
### 深链接
|
||||
|
||||
- 支持多端点导入(#597,感谢 @yovinchen)
|
||||
- 优先使用 `GOOGLE_GEMINI_BASE_URL` 而非 `GEMINI_BASE_URL`
|
||||
|
||||
### MCP
|
||||
|
||||
- WSL 目标路径跳过 `cmd /c` 包裹(#592,感谢 @cxyfer)
|
||||
|
||||
### 用量模板
|
||||
|
||||
- 新增变量提示,修复验证问题(#628,感谢 @YangYongAn)
|
||||
- 防止配置在供应商之间泄漏
|
||||
- 用量区块偏移量根据操作按钮宽度自动适应(#613,感谢 @yovinchen)
|
||||
|
||||
### Gemini
|
||||
|
||||
- 超时参数转换为 Gemini CLI 格式(#580,感谢 @cxyfer)
|
||||
|
||||
### UI
|
||||
|
||||
- 修复 `FullScreenPanel` 中 Select 下拉框渲染问题
|
||||
|
||||
---
|
||||
|
||||
## 说明与注意事项
|
||||
|
||||
- **OpenCode 为新支持的应用**:需要先安装 OpenCode CLI 才能使用相关功能。
|
||||
- **全局代理会影响所有出站请求**:包括用量查询、健康检查等网络操作。
|
||||
- **Rectifier 功能为实验性**:如遇问题可在高级设置中关闭。
|
||||
|
||||
---
|
||||
|
||||
## 特别感谢
|
||||
|
||||
感谢 @yovinchen @YangYongAn @cxyfer @xxk8 @kkkman22 @Shuimo03 为本版本做出的贡献!
|
||||
感谢 @libukai 设计的故障转移相关 UI,非常优雅!
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [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.10.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.10.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.10.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.10.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.10.2",
|
||||
"version": "3.9.1",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.10.2"
|
||||
version = "3.9.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.10.2"
|
||||
version = "3.9.1"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
+1
-26
@@ -1,28 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
|
||||
// Windows: Embed Common Controls v6 manifest for test binaries
|
||||
//
|
||||
// When running `cargo test`, the generated test executables don't include
|
||||
// the standard Tauri application manifest. Without Common Controls v6,
|
||||
// `tauri::test` calls fail with STATUS_ENTRYPOINT_NOT_FOUND.
|
||||
//
|
||||
// This workaround:
|
||||
// 1. Embeds the manifest into test binaries via /MANIFEST:EMBED
|
||||
// 2. Uses /MANIFEST:NO for the main binary to avoid duplicate resources
|
||||
// (Tauri already handles manifest embedding for the app binary)
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let manifest_path = std::path::PathBuf::from(
|
||||
std::env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR"),
|
||||
)
|
||||
.join("common-controls.manifest");
|
||||
let manifest_arg = format!("/MANIFESTINPUT:{}", manifest_path.display());
|
||||
|
||||
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg={}", manifest_arg);
|
||||
// Avoid duplicate manifest resources in binary builds.
|
||||
println!("cargo:rustc-link-arg-bins=/MANIFEST:NO");
|
||||
println!("cargo:rerun-if-changed={}", manifest_path.display());
|
||||
}
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
@@ -282,25 +282,6 @@ impl AppType {
|
||||
AppType::OpenCode => "opencode",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this app uses additive mode
|
||||
///
|
||||
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
|
||||
/// - Additive mode (true): All providers are written to live config (OpenCode)
|
||||
pub fn is_additive_mode(&self) -> bool {
|
||||
matches!(self, AppType::OpenCode)
|
||||
}
|
||||
|
||||
/// Return an iterator over all app types
|
||||
pub fn all() -> impl Iterator<Item = AppType> {
|
||||
[
|
||||
AppType::Claude,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
]
|
||||
.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AppType {
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{
|
||||
atomic_write, delete_file, get_home_dir, sanitize_provider_name, write_json_file,
|
||||
write_text_file,
|
||||
atomic_write, delete_file, sanitize_provider_name, write_json_file, write_text_file,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
use crate::database::FailoverQueueItem;
|
||||
use crate::provider::Provider;
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
use tauri::Emitter;
|
||||
|
||||
/// 获取故障转移队列
|
||||
#[tauri::command]
|
||||
@@ -86,47 +84,6 @@ pub async fn set_auto_failover_enabled(
|
||||
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||
);
|
||||
|
||||
// 强一致语义:开启故障转移后立即切到队列 P1(并确保队列非空)
|
||||
//
|
||||
// 说明:
|
||||
// - 仅在 enabled=true 时执行“切到 P1”
|
||||
// - 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户在 UI 上陷入死锁(无法先加队列再开启)
|
||||
let p1_provider_id = if enabled {
|
||||
let mut queue = state
|
||||
.db
|
||||
.get_failover_queue(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if queue.is_empty() {
|
||||
let app_enum = crate::app_config::AppType::from_str(&app_type)
|
||||
.map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
|
||||
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_enum)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(current_id) = current_id else {
|
||||
return Err("故障转移队列为空,且未设置当前供应商,无法开启故障转移".to_string());
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.add_to_failover_queue(&app_type, ¤t_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
queue = state
|
||||
.db
|
||||
.get_failover_queue(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
queue
|
||||
.first()
|
||||
.map(|item| item.provider_id.clone())
|
||||
.ok_or_else(|| "故障转移队列为空,无法开启故障转移".to_string())?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 读取当前配置
|
||||
let mut config = state
|
||||
.db
|
||||
@@ -144,22 +101,6 @@ pub async fn set_auto_failover_enabled(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 开启后立即切到 P1:更新 is_current + 本地 settings + Live 备份(接管模式下)
|
||||
if enabled {
|
||||
state
|
||||
.proxy_service
|
||||
.switch_proxy_target(&app_type, &p1_provider_id)
|
||||
.await?;
|
||||
|
||||
// 发射 provider-switched 事件(让前端刷新当前供应商)
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_type,
|
||||
"providerId": p1_provider_id,
|
||||
"source": "failoverEnabled"
|
||||
});
|
||||
let _ = app.emit("provider-switched", event_data);
|
||||
}
|
||||
|
||||
// 刷新托盘菜单,确保状态同步
|
||||
if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
|
||||
+82
-294
@@ -322,19 +322,6 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
||||
}
|
||||
|
||||
// 添加 fnm 路径支持
|
||||
let fnm_base = home.join(".local/state/fnm_multishells");
|
||||
if fnm_base.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&fnm_base) {
|
||||
for entry in entries.flatten() {
|
||||
let bin_path = entry.path().join("bin");
|
||||
if bin_path.exists() {
|
||||
search_paths.push(bin_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描 nvm 目录下的所有 node 版本
|
||||
let nvm_base = home.join(".nvm/versions/node");
|
||||
if nvm_base.exists() {
|
||||
@@ -539,15 +526,18 @@ fn launch_terminal_with_env(
|
||||
// 创建并写入配置文件
|
||||
write_claude_config(&config_file, &env_vars)?;
|
||||
|
||||
// 转义配置文件路径用于 shell
|
||||
let config_path_escaped = escape_shell_path(&config_file);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_macos_terminal(&config_file)?;
|
||||
launch_macos_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
launch_linux_terminal(&config_file)?;
|
||||
launch_linux_terminal(&config_file, &config_path_escaped)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -581,277 +571,115 @@ fn write_claude_config(
|
||||
std::fs::write(config_file, config_json).map_err(|e| format!("写入配置文件失败: {e}"))
|
||||
}
|
||||
|
||||
/// macOS: 根据用户首选终端启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("terminal");
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
|
||||
// Write the shell script to a temp file
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display()
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
|
||||
// Make script executable
|
||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||
|
||||
// Try the preferred terminal first, fall back to Terminal.app if it fails
|
||||
// Note: Kitty doesn't need the -e flag, others do
|
||||
let result = match terminal {
|
||||
"iterm2" => launch_macos_iterm2(&script_file),
|
||||
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
|
||||
};
|
||||
|
||||
// If preferred terminal fails and it's not the default, try Terminal.app as fallback
|
||||
if result.is_err() && terminal != "terminal" {
|
||||
log::warn!(
|
||||
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
|
||||
terminal,
|
||||
result.as_ref().err()
|
||||
);
|
||||
return launch_macos_terminal_app(&script_file);
|
||||
}
|
||||
|
||||
result
|
||||
/// 转义 shell 路径
|
||||
fn escape_shell_path(path: &std::path::Path) -> String {
|
||||
path.to_string_lossy()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('$', "\\$")
|
||||
.replace(' ', "\\ ")
|
||||
}
|
||||
|
||||
/// macOS: Terminal.app
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let applescript = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "bash '{}'"
|
||||
end tell"#,
|
||||
script_file.display()
|
||||
);
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&applescript)
|
||||
.output()
|
||||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Terminal.app 执行失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// 生成 bash 包装脚本,用于清理临时文件
|
||||
fn generate_wrapper_script(config_path: &str, escaped_path: &str) -> String {
|
||||
format!(
|
||||
"bash -c 'trap \"rm -f \\\"{config_path}\\\"\" EXIT; echo \"Using provider-specific claude config:\"; echo \"{escaped_path}\"; claude --settings \"{escaped_path}\"; exec bash --norc --noprofile'"
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: iTerm2
|
||||
/// macOS: 使用 Terminal.app 启动
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let applescript = format!(
|
||||
r#"tell application "iTerm"
|
||||
activate
|
||||
tell current window
|
||||
create tab with default profile
|
||||
tell current session
|
||||
write text "bash '{}'"
|
||||
end tell
|
||||
end tell
|
||||
end tell"#,
|
||||
script_file.display()
|
||||
);
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&applescript)
|
||||
.output()
|
||||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"iTerm2 执行失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// macOS: 使用 open -a 启动支持 --args 参数的终端(Alacritty/Kitty/Ghostty)
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_open_app(
|
||||
app_name: &str,
|
||||
script_file: &std::path::Path,
|
||||
use_e_flag: bool,
|
||||
fn launch_macos_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut cmd = Command::new("open");
|
||||
cmd.arg("-a").arg(app_name).arg("--args");
|
||||
let config_path_for_script = config_file.to_string_lossy().replace('\"', "\\\"");
|
||||
|
||||
if use_e_flag {
|
||||
cmd.arg("-e");
|
||||
}
|
||||
cmd.arg("bash").arg(script_file);
|
||||
let shell_script = generate_wrapper_script(&config_path_for_script, config_path_escaped);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("启动 {} 失败: {e}", app_name))?;
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{}"
|
||||
end tell"#,
|
||||
shell_script.replace('\"', "\\\"")
|
||||
);
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"{} 启动失败 (exit code: {:?}): {}",
|
||||
app_name,
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 macOS 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linux: 根据用户首选终端启动
|
||||
/// Linux: 尝试使用常见终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fn launch_linux_terminal(
|
||||
config_file: &std::path::Path,
|
||||
config_path_escaped: &str,
|
||||
) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
|
||||
// Default terminal list with their arguments
|
||||
let default_terminals = [
|
||||
("gnome-terminal", vec!["--"]),
|
||||
("konsole", vec!["-e"]),
|
||||
("xfce4-terminal", vec!["-e"]),
|
||||
("mate-terminal", vec!["--"]),
|
||||
("lxterminal", vec!["-e"]),
|
||||
("alacritty", vec!["-e"]),
|
||||
("kitty", vec!["-e"]),
|
||||
("ghostty", vec!["-e"]),
|
||||
let terminals = [
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"mate-terminal",
|
||||
"lxterminal",
|
||||
"alacritty",
|
||||
"kitty",
|
||||
];
|
||||
|
||||
// Create temp script file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
|
||||
let config_path = config_file.to_string_lossy();
|
||||
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
trap 'rm -f "{config_path}" "{script_file}"' EXIT
|
||||
echo "Using provider-specific claude config:"
|
||||
echo "{config_path}"
|
||||
claude --settings "{config_path}"
|
||||
exec bash --norc --noprofile
|
||||
"#,
|
||||
config_path = config_path,
|
||||
script_file = script_file.display()
|
||||
);
|
||||
|
||||
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
|
||||
|
||||
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
|
||||
|
||||
// Build terminal list: preferred terminal first (if specified), then defaults
|
||||
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
|
||||
// Find the preferred terminal's args from default list
|
||||
let pref_args = default_terminals
|
||||
.iter()
|
||||
.find(|(name, _)| *name == pref.as_str())
|
||||
.map(|(_, args)| args.iter().map(|s| *s).collect::<Vec<&str>>())
|
||||
.unwrap_or_else(|| vec!["-e"]); // Default args for unknown terminals
|
||||
|
||||
let mut list = vec![(pref.as_str(), pref_args)];
|
||||
// Add remaining terminals as fallbacks
|
||||
for (name, args) in &default_terminals {
|
||||
if *name != pref.as_str() {
|
||||
list.push((*name, args.iter().map(|s| *s).collect()));
|
||||
}
|
||||
}
|
||||
list
|
||||
} else {
|
||||
default_terminals
|
||||
.iter()
|
||||
.map(|(name, args)| (*name, args.iter().map(|s| *s).collect()))
|
||||
.collect()
|
||||
};
|
||||
let config_path_for_bash = config_file.to_string_lossy();
|
||||
let shell_cmd = generate_wrapper_script(&config_path_for_bash, config_path_escaped);
|
||||
|
||||
let mut last_error = String::from("未找到可用的终端");
|
||||
|
||||
for (terminal, args) in terminals_to_try {
|
||||
// Check if terminal exists in common paths
|
||||
let terminal_exists = std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
for terminal in terminals {
|
||||
// 检查终端是否存在
|
||||
if std::path::Path::new(&format!("/usr/bin/{}", terminal)).exists()
|
||||
|| std::path::Path::new(&format!("/bin/{}", terminal)).exists()
|
||||
|| std::path::Path::new(&format!("/usr/local/bin/{}", terminal)).exists()
|
||||
|| which_command(terminal);
|
||||
|
||||
if terminal_exists {
|
||||
let result = Command::new(terminal)
|
||||
.args(&args)
|
||||
.arg("bash")
|
||||
.arg(script_file.to_string_lossy().as_ref())
|
||||
.spawn();
|
||||
{
|
||||
let result = match terminal {
|
||||
"gnome-terminal" | "mate-terminal" => Command::new(terminal)
|
||||
.arg("--")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
_ => Command::new(terminal)
|
||||
.arg("-e")
|
||||
.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(&shell_cmd)
|
||||
.spawn(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = format!("执行 {} 失败: {}", terminal, e);
|
||||
last_error = format!("启动 {} 失败: {}", terminal, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up on failure
|
||||
let _ = std::fs::remove_file(&script_file);
|
||||
// 清理配置文件
|
||||
let _ = std::fs::remove_file(config_file);
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
/// Check if a command exists using `which`
|
||||
#[cfg(target_os = "linux")]
|
||||
fn which_command(cmd: &str) -> bool {
|
||||
use std::process::Command;
|
||||
Command::new("which")
|
||||
.arg(cmd)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Windows: 根据用户首选终端启动
|
||||
/// Windows: 创建临时批处理文件启动
|
||||
#[cfg(target_os = "windows")]
|
||||
fn launch_windows_terminal(
|
||||
temp_dir: &std::path::Path,
|
||||
config_file: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
let preferred = crate::settings::get_preferred_terminal();
|
||||
let terminal = preferred.as_deref().unwrap_or("cmd");
|
||||
use std::process::Command;
|
||||
|
||||
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
|
||||
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
|
||||
@@ -863,61 +691,21 @@ echo {}
|
||||
claude --settings \"{}\"
|
||||
del \"{}\" >nul 2>&1
|
||||
del \"%~f0\" >nul 2>&1
|
||||
",
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Press any key to close...
|
||||
pause >nul
|
||||
)",
|
||||
config_path_for_batch, config_path_for_batch, config_path_for_batch
|
||||
);
|
||||
|
||||
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
std::fs::write(&bat_file, content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
|
||||
let bat_path = bat_file.to_string_lossy();
|
||||
let ps_cmd = format!("& '{}'", bat_path);
|
||||
|
||||
// Try the preferred terminal first
|
||||
let result = match terminal {
|
||||
"powershell" => run_windows_start_command(
|
||||
&["powershell", "-NoExit", "-Command", &ps_cmd],
|
||||
"PowerShell",
|
||||
),
|
||||
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
|
||||
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
|
||||
};
|
||||
|
||||
// If preferred terminal fails and it's not the default, try cmd as fallback
|
||||
if result.is_err() && terminal != "cmd" {
|
||||
log::warn!(
|
||||
"首选终端 {} 启动失败,回退到 cmd: {:?}",
|
||||
terminal,
|
||||
result.as_ref().err()
|
||||
);
|
||||
return run_windows_start_command(&["cmd", "/K", &bat_path], "cmd");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Windows: Run a start command with common error handling
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut full_args = vec!["/C", "start"];
|
||||
full_args.extend(args);
|
||||
|
||||
let output = Command::new("cmd")
|
||||
.args(&full_args)
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"{} 启动失败 (exit code: {:?}): {}",
|
||||
terminal_name,
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Windows 终端失败: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! 提供前端调用的 API 接口
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
||||
use crate::store::AppState;
|
||||
@@ -120,120 +119,6 @@ pub async fn update_proxy_config_for_app(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn get_default_cost_multiplier_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let db = &state.db;
|
||||
db.get_default_cost_multiplier(app_type).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn get_default_cost_multiplier_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
get_default_cost_multiplier_internal(state, app_type).await
|
||||
}
|
||||
|
||||
/// 获取默认成本倍率
|
||||
#[tauri::command]
|
||||
pub async fn get_default_cost_multiplier(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<String, String> {
|
||||
get_default_cost_multiplier_internal(&state, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn set_default_cost_multiplier_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let db = &state.db;
|
||||
db.set_default_cost_multiplier(app_type, value).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn set_default_cost_multiplier_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
set_default_cost_multiplier_internal(state, app_type, value).await
|
||||
}
|
||||
|
||||
/// 设置默认成本倍率
|
||||
#[tauri::command]
|
||||
pub async fn set_default_cost_multiplier(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
set_default_cost_multiplier_internal(&state, &app_type, &value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn get_pricing_model_source_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let db = &state.db;
|
||||
db.get_pricing_model_source(app_type).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn get_pricing_model_source_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
) -> Result<String, AppError> {
|
||||
get_pricing_model_source_internal(state, app_type).await
|
||||
}
|
||||
|
||||
/// 获取计费模式来源
|
||||
#[tauri::command]
|
||||
pub async fn get_pricing_model_source(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
) -> Result<String, String> {
|
||||
get_pricing_model_source_internal(&state, &app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn set_pricing_model_source_internal(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let db = &state.db;
|
||||
db.set_pricing_model_source(app_type, value).await
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "test-hooks"), doc(hidden))]
|
||||
pub async fn set_pricing_model_source_test_hook(
|
||||
state: &AppState,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
set_pricing_model_source_internal(state, app_type, value).await
|
||||
}
|
||||
|
||||
/// 设置计费模式来源
|
||||
#[tauri::command]
|
||||
pub async fn set_pricing_model_source(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
set_pricing_model_source_internal(&state, &app_type, &value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 检查代理服务器是否正在运行
|
||||
#[tauri::command]
|
||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
|
||||
+5
-12
@@ -6,17 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
///
|
||||
/// On Windows, respects the `HOME` environment variable (if set) to support
|
||||
/// test isolation. Falls back to `dirs::home_dir()` otherwise.
|
||||
pub fn get_home_dir() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let trimmed = home.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
@@ -81,7 +71,10 @@ pub fn get_app_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::app_store::get_app_config_dir_override() {
|
||||
return custom;
|
||||
}
|
||||
get_home_dir().join(".cc-switch")
|
||||
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".cc-switch")
|
||||
}
|
||||
|
||||
/// 获取应用配置文件路径
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use super::super::{lock_conn, Database};
|
||||
|
||||
@@ -76,117 +75,6 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取默认成本倍率
|
||||
pub async fn get_default_cost_multiplier(&self, app_type: &str) -> Result<String, AppError> {
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT default_cost_multiplier FROM proxy_config WHERE app_type = ?1",
|
||||
[app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok("1".to_string())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置默认成本倍率
|
||||
pub async fn set_default_cost_multiplier(
|
||||
&self,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"error.multiplierEmpty",
|
||||
"倍率不能为空",
|
||||
"Multiplier cannot be empty",
|
||||
));
|
||||
}
|
||||
trimmed.parse::<Decimal>().map_err(|e| {
|
||||
AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - {e}"),
|
||||
format!("Invalid multiplier: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET
|
||||
default_cost_multiplier = ?2,
|
||||
updated_at = datetime('now')
|
||||
WHERE app_type = ?1",
|
||||
rusqlite::params![app_type, trimmed],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取计费模式来源
|
||||
pub async fn get_pricing_model_source(&self, app_type: &str) -> Result<String, AppError> {
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT pricing_model_source FROM proxy_config WHERE app_type = ?1",
|
||||
[app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok("response".to_string())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置计费模式来源
|
||||
pub async fn set_pricing_model_source(
|
||||
&self,
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let trimmed = value.trim();
|
||||
if !matches!(trimmed, "response" | "request") {
|
||||
return Err(AppError::localized(
|
||||
"error.invalidPricingMode",
|
||||
format!("无效计费模式: {value}"),
|
||||
format!("Invalid pricing mode: {value}"),
|
||||
));
|
||||
}
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET
|
||||
pricing_model_source = ?2,
|
||||
updated_at = datetime('now')
|
||||
WHERE app_type = ?1",
|
||||
rusqlite::params![app_type, trimmed],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取应用级代理配置
|
||||
pub async fn get_proxy_config_for_app(
|
||||
&self,
|
||||
@@ -289,90 +177,17 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确保指定 app_type 的 proxy_config 行存在(同步版本,用于 set_* 函数)
|
||||
///
|
||||
/// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||
fn ensure_proxy_config_row_exists(&self, app_type: &str) -> Result<(), AppError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| AppError::Lock(e.to_string()))?;
|
||||
|
||||
// 根据 app_type 使用不同的默认值(与 schema.rs seed 保持一致)
|
||||
let (retries, fb_timeout, idle_timeout, cb_fail, cb_succ, cb_timeout, cb_rate, cb_min) =
|
||||
match app_type {
|
||||
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
|
||||
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
|
||||
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES (?1, ?2, ?3, ?4, 600, ?5, ?6, ?7, ?8, ?9)",
|
||||
rusqlite::params![
|
||||
app_type,
|
||||
retries,
|
||||
fb_timeout,
|
||||
idle_timeout,
|
||||
cb_fail,
|
||||
cb_succ,
|
||||
cb_timeout,
|
||||
cb_rate,
|
||||
cb_min
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化 proxy_config 表的三行数据
|
||||
///
|
||||
/// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 使用与 schema.rs seed 相同的 per-app 默认值
|
||||
// claude: 更激进的重试和超时配置
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// codex: 默认配置
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// gemini: 稍高的重试次数
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (
|
||||
app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests
|
||||
) VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
for app_type in &["claude", "codex", "gemini"] {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
||||
[app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -847,58 +662,3 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_cost_multiplier_round_trip() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
let default = db.get_default_cost_multiplier("claude").await?;
|
||||
assert_eq!(default, "1");
|
||||
|
||||
db.set_default_cost_multiplier("claude", "1.5").await?;
|
||||
let updated = db.get_default_cost_multiplier("claude").await?;
|
||||
assert_eq!(updated, "1.5");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_cost_multiplier_validation() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
let err = db
|
||||
.set_default_cost_multiplier("claude", "not-a-number")
|
||||
.await
|
||||
.unwrap_err();
|
||||
// AppError::localized returns AppError::Localized variant
|
||||
assert!(matches!(err, AppError::Localized { key: "error.invalidMultiplier", .. }));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pricing_model_source_round_trip_and_validation() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
let default = db.get_pricing_model_source("claude").await?;
|
||||
assert_eq!(default, "response");
|
||||
|
||||
db.set_pricing_model_source("claude", "request").await?;
|
||||
let updated = db.get_pricing_model_source("claude").await?;
|
||||
assert_eq!(updated, "request");
|
||||
|
||||
let err = db
|
||||
.set_pricing_model_source("claude", "invalid")
|
||||
.await
|
||||
.unwrap_err();
|
||||
// AppError::localized returns AppError::Localized variant
|
||||
assert!(matches!(err, AppError::Localized { key: "error.invalidPricingMode", .. }));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,32 +203,24 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 初始化默认的 Skill 仓库(启动时调用,补充缺失的默认仓库)
|
||||
/// 初始化默认的 Skill 仓库(首次启动时调用)
|
||||
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
|
||||
// 获取已有仓库列表
|
||||
// 检查是否已有仓库
|
||||
let existing = self.get_skill_repos()?;
|
||||
let existing_keys: std::collections::HashSet<(String, String)> = existing
|
||||
.iter()
|
||||
.map(|r| (r.owner.clone(), r.name.clone()))
|
||||
.collect();
|
||||
if !existing.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 获取默认仓库列表
|
||||
let default_store = crate::services::skill::SkillStore::default();
|
||||
let mut count = 0;
|
||||
|
||||
// 仅插入缺失的默认仓库
|
||||
for repo in &default_store.repos {
|
||||
let key = (repo.owner.clone(), repo.name.clone());
|
||||
if !existing_keys.contains(&key) {
|
||||
self.save_skill_repo(repo)?;
|
||||
count += 1;
|
||||
log::info!("补充默认 Skill 仓库: {}/{}", repo.owner, repo.name);
|
||||
}
|
||||
self.save_skill_repo(repo)?;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
log::info!("补充默认 Skill 仓库完成,新增 {count} 个");
|
||||
}
|
||||
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 4;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -120,8 +120,6 @@ impl Database {
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -172,7 +170,6 @@ impl Database {
|
||||
// 10. Proxy Request Logs 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
@@ -355,11 +352,6 @@ impl Database {
|
||||
Self::migrate_v3_to_v4(conn)?;
|
||||
Self::set_user_version(conn, 4)?;
|
||||
}
|
||||
4 => {
|
||||
log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
|
||||
Self::migrate_v4_to_v5(conn)?;
|
||||
Self::set_user_version(conn, 5)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -529,7 +521,6 @@ impl Database {
|
||||
// proxy_request_logs 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
@@ -686,8 +677,6 @@ impl Database {
|
||||
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
|
||||
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
|
||||
pricing_model_source TEXT NOT NULL DEFAULT 'response',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)", [])?;
|
||||
|
||||
@@ -890,30 +879,6 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v4 -> v5 迁移:新增计费模式配置与请求模型字段
|
||||
fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
|
||||
if Self::table_exists(conn, "proxy_config")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"default_cost_multiplier",
|
||||
"TEXT NOT NULL DEFAULT '1'",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"pricing_model_source",
|
||||
"TEXT NOT NULL DEFAULT 'response'",
|
||||
)?;
|
||||
}
|
||||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
|
||||
}
|
||||
|
||||
log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
@@ -151,7 +151,7 @@ fn normalize_default(default: &Option<String>) -> Option<String> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_migration_sets_user_version_when_missing() {
|
||||
fn migration_sets_user_version_when_missing() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
@@ -169,7 +169,7 @@ fn schema_migration_sets_user_version_when_missing() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_migration_rejects_future_version() {
|
||||
fn migration_rejects_future_version() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||||
@@ -183,7 +183,7 @@ fn schema_migration_rejects_future_version() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_migration_adds_missing_columns_for_providers() {
|
||||
fn migration_adds_missing_columns_for_providers() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
// 创建旧版 providers 表,缺少新增列
|
||||
@@ -224,7 +224,7 @@ fn schema_migration_adds_missing_columns_for_providers() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_migration_aligns_column_defaults_and_types() {
|
||||
fn migration_aligns_column_defaults_and_types() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
conn.execute_batch(LEGACY_SCHEMA_SQL)
|
||||
.expect("seed old schema");
|
||||
@@ -268,67 +268,7 @@ fn schema_migration_aligns_column_defaults_and_types() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_create_tables_include_pricing_model_columns() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
|
||||
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
|
||||
assert_eq!(multiplier.r#type, "TEXT");
|
||||
assert_eq!(multiplier.notnull, 1);
|
||||
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
|
||||
|
||||
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
|
||||
assert_eq!(pricing_source.r#type, "TEXT");
|
||||
assert_eq!(pricing_source.notnull, 1);
|
||||
assert_eq!(
|
||||
normalize_default(&pricing_source.default).as_deref(),
|
||||
Some("response")
|
||||
);
|
||||
|
||||
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
|
||||
assert_eq!(request_model.r#type, "TEXT");
|
||||
assert_eq!(request_model.notnull, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
|
||||
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
|
||||
"#,
|
||||
)
|
||||
.expect("seed v4 schema");
|
||||
|
||||
Database::set_user_version(&conn, 4).expect("set user_version=4");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
|
||||
let multiplier = get_column_info(&conn, "proxy_config", "default_cost_multiplier");
|
||||
assert_eq!(multiplier.r#type, "TEXT");
|
||||
assert_eq!(multiplier.notnull, 1);
|
||||
assert_eq!(normalize_default(&multiplier.default).as_deref(), Some("1"));
|
||||
|
||||
let pricing_source = get_column_info(&conn, "proxy_config", "pricing_model_source");
|
||||
assert_eq!(pricing_source.r#type, "TEXT");
|
||||
assert_eq!(pricing_source.notnull, 1);
|
||||
assert_eq!(
|
||||
normalize_default(&pricing_source.default).as_deref(),
|
||||
Some("response")
|
||||
);
|
||||
|
||||
let request_model = get_column_info(&conn, "proxy_request_logs", "request_model");
|
||||
assert_eq!(request_model.r#type, "TEXT");
|
||||
assert_eq!(request_model.notnull, 0);
|
||||
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("version after migration"),
|
||||
SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
||||
@@ -493,7 +433,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_dry_run_does_not_write_to_disk() {
|
||||
fn dry_run_does_not_write_to_disk() {
|
||||
// Create minimal valid config for migration
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
@@ -567,7 +507,7 @@ fn dry_run_validates_schema_compatibility() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_model_pricing_is_seeded_on_init() {
|
||||
fn model_pricing_is_seeded_on_init() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
|
||||
let conn = db.conn.lock().expect("lock conn");
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
use crate::config::{get_home_dir, write_text_file};
|
||||
use crate::config::write_text_file;
|
||||
use crate::error::AppError;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 获取用户主目录,带回退和日志
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| {
|
||||
log::warn!("无法获取用户主目录,回退到当前目录");
|
||||
PathBuf::from(".")
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||||
pub fn get_gemini_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||||
|
||||
@@ -745,24 +745,6 @@ pub fn run() {
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
});
|
||||
|
||||
// 静默启动:根据设置决定是否显示主窗口
|
||||
let settings = crate::settings::get_settings();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if settings.silent_startup {
|
||||
// 静默启动模式:保持窗口隐藏
|
||||
let _ = window.hide();
|
||||
#[cfg(target_os = "windows")]
|
||||
let _ = window.set_skip_taskbar(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
tray::apply_tray_policy(app.handle(), false);
|
||||
log::info!("静默启动模式:主窗口已隐藏");
|
||||
} else {
|
||||
// 正常启动模式:显示窗口
|
||||
let _ = window.show();
|
||||
log::info!("正常启动模式:主窗口已显示");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -895,10 +877,6 @@ pub fn run() {
|
||||
commands::update_global_proxy_config,
|
||||
commands::get_proxy_config_for_app,
|
||||
commands::update_proxy_config_for_app,
|
||||
commands::get_default_cost_multiplier,
|
||||
commands::set_default_cost_multiplier,
|
||||
commands::get_pricing_model_source,
|
||||
commands::set_pricing_model_source,
|
||||
commands::is_proxy_running,
|
||||
commands::is_live_takeover_active,
|
||||
commands::switch_proxy_provider,
|
||||
|
||||
@@ -42,10 +42,21 @@ pub fn get_opencode_dir() -> PathBuf {
|
||||
return override_dir;
|
||||
}
|
||||
|
||||
// 所有平台统一使用 ~/.config/opencode
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config").join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: %APPDATA%\opencode
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Unix: ~/.config/opencode
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config").join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from(".config").join("opencode"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 OpenCode 配置文件路径
|
||||
|
||||
@@ -215,9 +215,6 @@ pub struct ProviderMeta {
|
||||
/// 成本倍数(用于计算实际成本)
|
||||
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
||||
pub cost_multiplier: Option<String>,
|
||||
/// 计费模式来源(response/request)
|
||||
#[serde(rename = "pricingModelSource", skip_serializing_if = "Option::is_none")]
|
||||
pub pricing_model_source: Option<String>,
|
||||
/// 每日消费限额(USD)
|
||||
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
||||
pub limit_daily_usd: Option<String>,
|
||||
@@ -599,11 +596,6 @@ pub struct OpenCodeModel {
|
||||
/// 模型额外选项(provider 路由等)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<HashMap<String, Value>>,
|
||||
|
||||
/// 额外字段(cost、modalities、thinking、variants 等)
|
||||
/// 使用 flatten 捕获所有未明确定义的字段
|
||||
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// OpenCode 模型限制
|
||||
@@ -617,267 +609,3 @@ pub struct OpenCodeModelLimit {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||
ProviderManager, ProviderMeta, UniversalProvider,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_meta_serializes_pricing_model_source() {
|
||||
let mut meta = ProviderMeta::default();
|
||||
meta.pricing_model_source = Some("response".to_string());
|
||||
|
||||
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||
|
||||
assert_eq!(
|
||||
value
|
||||
.get("pricingModelSource")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("response")
|
||||
);
|
||||
assert!(value.get("pricing_model_source").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_meta_omits_pricing_model_source_when_none() {
|
||||
let meta = ProviderMeta::default();
|
||||
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||
|
||||
assert!(value.get("pricingModelSource").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_with_id_populates_defaults() {
|
||||
let settings_config = json!({
|
||||
"env": { "API_KEY": "test" }
|
||||
});
|
||||
let provider = Provider::with_id(
|
||||
"provider-1".to_string(),
|
||||
"Provider".to_string(),
|
||||
settings_config.clone(),
|
||||
Some("https://example.com".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(provider.id, "provider-1");
|
||||
assert_eq!(provider.name, "Provider");
|
||||
assert_eq!(provider.settings_config, settings_config);
|
||||
assert_eq!(provider.website_url.as_deref(), Some("https://example.com"));
|
||||
assert!(provider.category.is_none());
|
||||
assert!(provider.created_at.is_none());
|
||||
assert!(provider.sort_index.is_none());
|
||||
assert!(provider.notes.is_none());
|
||||
assert!(provider.meta.is_none());
|
||||
assert!(provider.icon.is_none());
|
||||
assert!(provider.icon_color.is_none());
|
||||
assert!(!provider.in_failover_queue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_manager_get_all_providers_returns_map() {
|
||||
let mut manager = ProviderManager::default();
|
||||
let provider = Provider::with_id(
|
||||
"provider-1".to_string(),
|
||||
"Provider".to_string(),
|
||||
json!({ "env": {} }),
|
||||
None,
|
||||
);
|
||||
manager.providers.insert("provider-1".to_string(), provider);
|
||||
|
||||
assert_eq!(manager.get_all_providers().len(), 1);
|
||||
assert!(manager.get_all_providers().contains_key("provider-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_claude_provider_uses_models() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.claude = true;
|
||||
universal.models.claude = Some(ClaudeModelConfig {
|
||||
model: Some("claude-main".to_string()),
|
||||
haiku_model: Some("claude-haiku".to_string()),
|
||||
sonnet_model: Some("claude-sonnet".to_string()),
|
||||
opus_model: Some("claude-opus".to_string()),
|
||||
});
|
||||
|
||||
let provider = universal.to_claude_provider().expect("claude provider");
|
||||
|
||||
assert_eq!(provider.id, "universal-claude-u1");
|
||||
assert_eq!(provider.name, "Universal");
|
||||
assert_eq!(provider.category.as_deref(), Some("aggregator"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-main")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-haiku")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-sonnet")
|
||||
);
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("claude-opus")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_claude_provider_disabled_returns_none() {
|
||||
let universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
|
||||
assert!(universal.to_claude_provider().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_codex_provider_appends_v1() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.codex = true;
|
||||
universal.models.codex = Some(CodexModelConfig {
|
||||
model: Some("gpt-4o-mini".to_string()),
|
||||
reasoning_effort: Some("low".to_string()),
|
||||
});
|
||||
|
||||
let provider = universal.to_codex_provider().expect("codex provider");
|
||||
let config = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|item| item.as_str())
|
||||
.expect("config toml");
|
||||
|
||||
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/auth/OPENAI_API_KEY")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("api-key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_codex_provider_keeps_v1_suffix() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com/v1".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.codex = true;
|
||||
|
||||
let provider = universal.to_codex_provider().expect("codex provider");
|
||||
let config = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|item| item.as_str())
|
||||
.expect("config toml");
|
||||
|
||||
assert!(config.contains("base_url = \"https://api.example.com/v1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_codex_provider_disabled_returns_none() {
|
||||
let universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
|
||||
assert!(universal.to_codex_provider().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_gemini_provider_defaults_model() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.gemini = true;
|
||||
|
||||
let provider = universal.to_gemini_provider().expect("gemini provider");
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/GEMINI_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("gemini-2.5-pro")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_provider_to_gemini_provider_uses_model() {
|
||||
let mut universal = UniversalProvider::new(
|
||||
"u1".to_string(),
|
||||
"Universal".to_string(),
|
||||
"newapi".to_string(),
|
||||
"https://api.example.com".to_string(),
|
||||
"api-key".to_string(),
|
||||
);
|
||||
universal.apps.gemini = true;
|
||||
universal.models.gemini = Some(GeminiModelConfig {
|
||||
model: Some("gemini-custom".to_string()),
|
||||
});
|
||||
|
||||
let provider = universal.to_gemini_provider().expect("gemini provider");
|
||||
|
||||
assert_eq!(
|
||||
provider
|
||||
.settings_config
|
||||
.pointer("/env/GEMINI_MODEL")
|
||||
.and_then(|item| item.as_str()),
|
||||
Some("gemini-custom")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_provider_config_defaults() {
|
||||
let config = OpenCodeProviderConfig::default();
|
||||
assert_eq!(config.npm, "@ai-sdk/openai-compatible");
|
||||
assert!(config.name.is_none());
|
||||
assert!(config.models.is_empty());
|
||||
assert!(config.options.base_url.is_none());
|
||||
assert!(config.options.api_key.is_none());
|
||||
assert!(config.options.headers.is_none());
|
||||
assert!(config.options.extra.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ use super::{
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
|
||||
// ============================================================================
|
||||
// 健康检查和状态查询(简单端点)
|
||||
@@ -143,7 +145,6 @@ async fn handle_claude_transform(
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
&model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -214,7 +215,6 @@ async fn handle_claude_transform(
|
||||
.unwrap_or("unknown");
|
||||
let latency_ms = ctx.latency_ms();
|
||||
|
||||
let request_model = ctx.request_model.clone();
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
@@ -225,7 +225,6 @@ async fn handle_claude_transform(
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -442,7 +441,6 @@ async fn log_usage(
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
usage: TokenUsage,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
@@ -453,12 +451,25 @@ async fn log_usage(
|
||||
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
// 获取 provider 的 cost_multiplier
|
||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
||||
Ok(Some(p)) => {
|
||||
if let Some(meta) = p.meta {
|
||||
if let Some(cm) = meta.cost_multiplier {
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
}
|
||||
_ => Decimal::from(1),
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -468,8 +479,6 @@ async fn log_usage(
|
||||
provider_id.to_string(),
|
||||
app_type.to_string(),
|
||||
model.to_string(),
|
||||
request_model.to_string(),
|
||||
pricing_model.to_string(),
|
||||
usage,
|
||||
multiplier,
|
||||
latency_ms,
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
//!
|
||||
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -33,7 +31,7 @@ impl ProviderRouter {
|
||||
///
|
||||
/// 返回按优先级排序的可用供应商列表:
|
||||
/// - 故障转移关闭时:仅返回当前供应商
|
||||
/// - 故障转移开启时:仅使用故障转移队列,按队列顺序依次尝试(P1 → P2 → ...)
|
||||
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
let mut total_providers = 0usize;
|
||||
@@ -49,25 +47,12 @@ impl ProviderRouter {
|
||||
};
|
||||
|
||||
if auto_failover_enabled {
|
||||
// 故障转移开启:仅按队列顺序依次尝试(P1 → P2 → ...)
|
||||
let all_providers = self.db.get_all_providers(app_type)?;
|
||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||
total_providers = failover_providers.len();
|
||||
|
||||
// 使用 DAO 返回的排序结果,确保和前端展示一致
|
||||
let ordered_ids: Vec<String> = self
|
||||
.db
|
||||
.get_failover_queue(app_type)?
|
||||
.into_iter()
|
||||
.map(|item| item.provider_id)
|
||||
.collect();
|
||||
|
||||
total_providers = ordered_ids.len();
|
||||
|
||||
for provider_id in ordered_ids {
|
||||
let Some(provider) = all_providers.get(&provider_id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let circuit_key = format!("{app_type}:{}", provider.id);
|
||||
for provider in failover_providers {
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
@@ -78,16 +63,7 @@ impl ProviderRouter {
|
||||
}
|
||||
} else {
|
||||
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
||||
let current_id = AppType::from_str(app_type)
|
||||
.ok()
|
||||
.and_then(|app_enum| {
|
||||
crate::settings::get_effective_current_provider(&self.db, &app_enum)
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.or_else(|| self.db.get_current_provider(app_type).ok().flatten());
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||
total_providers = 1;
|
||||
result.push(current);
|
||||
@@ -263,53 +239,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use serde_json::json;
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct TempHome {
|
||||
#[allow(dead_code)]
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().expect("failed to create temp home");
|
||||
let original_home = env::var("HOME").ok();
|
||||
let original_userprofile = env::var("USERPROFILE").ok();
|
||||
|
||||
env::set_var("HOME", dir.path());
|
||||
env::set_var("USERPROFILE", dir.path());
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
Self {
|
||||
dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempHome {
|
||||
fn drop(&mut self) {
|
||||
match &self.original_home {
|
||||
Some(value) => env::set_var("HOME", value),
|
||||
None => env::remove_var("HOME"),
|
||||
}
|
||||
|
||||
match &self.original_userprofile {
|
||||
Some(value) => env::set_var("USERPROFILE", value),
|
||||
None => env::remove_var("USERPROFILE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_provider_router_creation() {
|
||||
let _home = TempHome::new();
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
let router = ProviderRouter::new(db);
|
||||
|
||||
@@ -318,9 +250,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_failover_disabled_uses_current_provider() {
|
||||
let _home = TempHome::new();
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
let provider_a =
|
||||
@@ -341,9 +271,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_failover_enabled_uses_queue_order_ignoring_current() {
|
||||
let _home = TempHome::new();
|
||||
async fn test_failover_enabled_uses_queue_order() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||
@@ -370,45 +298,13 @@ mod tests {
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
|
||||
assert_eq!(providers.len(), 2);
|
||||
// 故障转移开启时:仅按队列顺序选择(忽略当前供应商)
|
||||
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
||||
assert_eq!(providers[0].id, "b");
|
||||
assert_eq!(providers[1].id, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_failover_enabled_uses_queue_only_even_if_current_not_in_queue() {
|
||||
let _home = TempHome::new();
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
let mut provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
provider_b.sort_index = Some(1);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
|
||||
// 只把 b 加入故障转移队列(模拟“当前供应商不在队列里”的常见配置)
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
|
||||
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||
config.auto_failover_enabled = true;
|
||||
db.update_proxy_config_for_app(config).await.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].id, "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||
let _home = TempHome::new();
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||
@@ -449,9 +345,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_release_permit_neutral_frees_half_open_slot() {
|
||||
let _home = TempHome::new();
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 配置熔断器:1 次失败即熔断,0 秒超时立即进入 HalfOpen
|
||||
|
||||
@@ -13,8 +13,10 @@ use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use reqwest::header::HeaderMap;
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
str::FromStr,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
@@ -126,15 +128,7 @@ pub async fn handle_non_streaming(
|
||||
ctx.request_model.clone()
|
||||
};
|
||||
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
usage,
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
|
||||
} else {
|
||||
let model = json_value
|
||||
.get("model")
|
||||
@@ -146,7 +140,6 @@ pub async fn handle_non_streaming(
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
@@ -166,7 +159,6 @@ pub async fn handle_non_streaming(
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&ctx.request_model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
@@ -301,7 +293,6 @@ fn create_usage_collector(
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -309,7 +300,6 @@ fn create_usage_collector(
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -325,7 +315,6 @@ fn create_usage_collector(
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -333,7 +322,6 @@ fn create_usage_collector(
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
TokenUsage::default(),
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -354,7 +342,6 @@ fn spawn_log_usage(
|
||||
ctx: &RequestContext,
|
||||
usage: TokenUsage,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
status_code: u16,
|
||||
is_streaming: bool,
|
||||
) {
|
||||
@@ -362,7 +349,6 @@ fn spawn_log_usage(
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let app_type_str = ctx.app_type_str.to_string();
|
||||
let model = model.to_string();
|
||||
let request_model = request_model.to_string();
|
||||
let latency_ms = ctx.latency_ms();
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
@@ -372,7 +358,6 @@ fn spawn_log_usage(
|
||||
&provider_id,
|
||||
&app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -391,7 +376,6 @@ async fn log_usage_internal(
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
usage: TokenUsage,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
@@ -402,12 +386,26 @@ async fn log_usage_internal(
|
||||
use super::usage::logger::UsageLogger;
|
||||
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
|
||||
// 获取 provider 的 cost_multiplier
|
||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
||||
Ok(Some(p)) => {
|
||||
if let Some(meta) = p.meta {
|
||||
if let Some(cm) = meta.cost_multiplier {
|
||||
Decimal::from_str(&cm).unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
})
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
} else {
|
||||
Decimal::from(1)
|
||||
}
|
||||
}
|
||||
_ => Decimal::from(1),
|
||||
};
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -426,8 +424,6 @@ async fn log_usage_internal(
|
||||
provider_id.to_string(),
|
||||
app_type.to_string(),
|
||||
model.to_string(),
|
||||
request_model.to_string(),
|
||||
pricing_model.to_string(),
|
||||
usage,
|
||||
multiplier,
|
||||
latency_ms,
|
||||
@@ -560,185 +556,3 @@ fn format_headers(headers: &HeaderMap) -> String {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::ProviderMeta;
|
||||
use crate::proxy::failover_switch::FailoverSwitchManager;
|
||||
use crate::proxy::provider_router::ProviderRouter;
|
||||
use crate::proxy::types::{ProxyConfig, ProxyStatus};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||
ProxyState {
|
||||
db: db.clone(),
|
||||
config: Arc::new(RwLock::new(ProxyConfig::default())),
|
||||
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(HashMap::new())),
|
||||
provider_router: Arc::new(ProviderRouter::new(db.clone())),
|
||||
app_handle: None,
|
||||
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_pricing(db: &Database) -> Result<(), AppError> {
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["resp-model", "Resp Model", "1.0", "0"],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params!["req-model", "Req Model", "2.0", "0"],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_provider(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
app_type: &str,
|
||||
meta: ProviderMeta,
|
||||
) -> Result<(), AppError> {
|
||||
let meta_json =
|
||||
serde_json::to_string(&meta).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![id, app_type, "Test Provider", "{}", meta_json],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_usage_uses_provider_override_config() -> Result<(), AppError> {
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let app_type = "claude";
|
||||
|
||||
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||
db.set_pricing_model_source(app_type, "response").await?;
|
||||
seed_pricing(&db)?;
|
||||
|
||||
let mut meta = ProviderMeta::default();
|
||||
meta.cost_multiplier = Some("2".to_string());
|
||||
meta.pricing_model_source = Some("request".to_string());
|
||||
insert_provider(&db, "provider-1", app_type, meta)?;
|
||||
|
||||
let state = build_state(db.clone());
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
&state,
|
||||
"provider-1",
|
||||
app_type,
|
||||
"resp-model",
|
||||
"req-model",
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
false,
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (model, request_model, total_cost, cost_multiplier): (String, String, String, String) =
|
||||
conn.query_row(
|
||||
"SELECT model, request_model, total_cost_usd, cost_multiplier
|
||||
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||
["provider-1"],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
assert_eq!(model, "resp-model");
|
||||
assert_eq!(request_model, "req-model");
|
||||
assert_eq!(
|
||||
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||
Decimal::from_str("2").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Decimal::from_str(&total_cost).unwrap(),
|
||||
Decimal::from_str("4").unwrap()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let app_type = "claude";
|
||||
|
||||
db.set_default_cost_multiplier(app_type, "1.5").await?;
|
||||
db.set_pricing_model_source(app_type, "response").await?;
|
||||
seed_pricing(&db)?;
|
||||
|
||||
let meta = ProviderMeta::default();
|
||||
insert_provider(&db, "provider-2", app_type, meta)?;
|
||||
|
||||
let state = build_state(db.clone());
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
};
|
||||
|
||||
log_usage_internal(
|
||||
&state,
|
||||
"provider-2",
|
||||
app_type,
|
||||
"resp-model",
|
||||
"req-model",
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
false,
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (total_cost, cost_multiplier): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT total_cost_usd, cost_multiplier
|
||||
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||
["provider-2"],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
assert_eq!(
|
||||
Decimal::from_str(&cost_multiplier).unwrap(),
|
||||
Decimal::from_str("1.5").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
Decimal::from_str(&total_cost).unwrap(),
|
||||
Decimal::from_str("1.5").unwrap()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,18 +190,6 @@ impl ProxyServer {
|
||||
status
|
||||
}
|
||||
|
||||
/// 更新某个应用类型当前“目标供应商”(用于 UI 展示 active_targets)
|
||||
///
|
||||
/// 注意:这不代表该供应商一定已经处理过请求,而是用于“热切换/启用故障转移立即切 P1”
|
||||
/// 等场景下,让 UI 能立刻反映最新目标。
|
||||
pub async fn set_active_target(&self, app_type: &str, provider_id: &str, provider_name: &str) {
|
||||
let mut current_providers = self.state.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type.to_string(),
|
||||
(provider_id.to_string(), provider_name.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn build_router(&self) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
|
||||
@@ -195,19 +195,28 @@ pub struct AppProxyConfig {
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 settings 表中
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectifierConfig {
|
||||
/// 总开关:是否启用整流器
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 请求整流:启用 thinking 签名整流器
|
||||
///
|
||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_signature: bool,
|
||||
}
|
||||
|
||||
impl Default for RectifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -261,34 +270,35 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_default_disabled() {
|
||||
// 验证 RectifierConfig::default() 返回全禁用状态
|
||||
fn test_rectifier_config_default_enabled() {
|
||||
// 验证 RectifierConfig::default() 返回全启用状态
|
||||
// 防止回归:#[derive(Default)] 会使 bool 默认为 false
|
||||
let config = RectifierConfig::default();
|
||||
assert!(!config.enabled, "整流器总开关默认应为 false");
|
||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||
assert!(
|
||||
!config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 false"
|
||||
config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_default() {
|
||||
// 验证反序列化缺字段时使用默认值 false
|
||||
// 验证反序列化缺字段时使用 default_true
|
||||
let json = "{}";
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_true() {
|
||||
// 验证显式设置 true 时正确反序列化
|
||||
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_false() {
|
||||
// 验证显式设置 false 时正确反序列化
|
||||
let json = r#"{"enabled": false, "requestThinkingSignature": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_config_default() {
|
||||
let config = LogConfig::default();
|
||||
@@ -306,48 +316,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_log_config_to_level_filter() {
|
||||
let config = LogConfig {
|
||||
level: "error".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut config = LogConfig::default();
|
||||
|
||||
config.level = "error".to_string();
|
||||
assert_eq!(config.to_level_filter(), log::LevelFilter::Error);
|
||||
|
||||
let config = LogConfig {
|
||||
level: "warn".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
config.level = "warn".to_string();
|
||||
assert_eq!(config.to_level_filter(), log::LevelFilter::Warn);
|
||||
|
||||
let config = LogConfig {
|
||||
level: "info".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
config.level = "info".to_string();
|
||||
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
|
||||
|
||||
let config = LogConfig {
|
||||
level: "debug".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
config.level = "debug".to_string();
|
||||
assert_eq!(config.to_level_filter(), log::LevelFilter::Debug);
|
||||
|
||||
let config = LogConfig {
|
||||
level: "trace".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
config.level = "trace".to_string();
|
||||
assert_eq!(config.to_level_filter(), log::LevelFilter::Trace);
|
||||
|
||||
// 无效级别回退到 info
|
||||
let config = LogConfig {
|
||||
level: "invalid".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
config.level = "invalid".to_string();
|
||||
assert_eq!(config.to_level_filter(), log::LevelFilter::Info);
|
||||
|
||||
// 禁用时返回 Off
|
||||
let config = LogConfig {
|
||||
enabled: false,
|
||||
level: "debug".to_string(),
|
||||
};
|
||||
config.enabled = false;
|
||||
config.level = "debug".to_string();
|
||||
assert_eq!(config.to_level_filter(), log::LevelFilter::Off);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ impl CostCalculator {
|
||||
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
||||
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
||||
/// - 这样避免缓存部分被重复计费
|
||||
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
|
||||
pub fn calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
@@ -51,20 +50,21 @@ impl CostCalculator {
|
||||
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
||||
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
||||
|
||||
// 各项基础成本(不含倍率)
|
||||
let input_cost =
|
||||
Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million;
|
||||
let output_cost =
|
||||
Decimal::from(usage.output_tokens) * pricing.output_cost_per_million / million;
|
||||
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
let cache_read_cost =
|
||||
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million;
|
||||
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
|
||||
* cost_multiplier;
|
||||
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
|
||||
* pricing.cache_creation_cost_per_million
|
||||
/ million;
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
|
||||
// 总成本 = 各项基础成本之和 × 倍率
|
||||
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||
let total_cost = base_total * cost_multiplier;
|
||||
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||
|
||||
CostBreakdown {
|
||||
input_cost,
|
||||
@@ -151,9 +151,8 @@ mod tests {
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// input_cost: 基础价格(不含倍率)= 1000 * 3.0 / 1M = 0.003
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||
// total_cost: 基础价格 × 倍率 = 0.003 * 1.5 = 0.0045
|
||||
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::find_model_pricing_row;
|
||||
use rust_decimal::Decimal;
|
||||
use std::{str::FromStr, time::SystemTime};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 请求日志
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -15,7 +15,6 @@ pub struct RequestLog {
|
||||
pub provider_id: String,
|
||||
pub app_type: String,
|
||||
pub model: String,
|
||||
pub request_model: String,
|
||||
pub usage: TokenUsage,
|
||||
pub cost: Option<CostBreakdown>,
|
||||
pub latency_ms: u64,
|
||||
@@ -74,18 +73,17 @@ impl<'a> UsageLogger<'a> {
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)",
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
|
||||
rusqlite::params![
|
||||
log.request_id,
|
||||
log.provider_id,
|
||||
log.app_type,
|
||||
log.model,
|
||||
log.request_model,
|
||||
log.usage.input_tokens,
|
||||
log.usage.output_tokens,
|
||||
log.usage.cache_read_tokens,
|
||||
@@ -125,13 +123,11 @@ impl<'a> UsageLogger<'a> {
|
||||
error_message: String,
|
||||
latency_ms: u64,
|
||||
) -> Result<(), AppError> {
|
||||
let request_model = model.clone();
|
||||
let log = RequestLog {
|
||||
request_id,
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -164,13 +160,11 @@ impl<'a> UsageLogger<'a> {
|
||||
session_id: Option<String>,
|
||||
provider_type: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let request_model = model.clone();
|
||||
let log = RequestLog {
|
||||
request_id,
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -200,88 +194,6 @@ impl<'a> UsageLogger<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取有效的倍率与计费模式来源(供应商优先,未配置则回退全局默认)
|
||||
pub async fn resolve_pricing_config(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> (Decimal, String) {
|
||||
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
|
||||
"1".to_string()
|
||||
}
|
||||
};
|
||||
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[USG-003] 默认倍率解析失败 (app_type={app_type}): {default_multiplier_raw} - {e}"
|
||||
);
|
||||
Decimal::from(1)
|
||||
}
|
||||
};
|
||||
|
||||
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||
"response".to_string()
|
||||
}
|
||||
};
|
||||
let default_pricing_source =
|
||||
if matches!(default_pricing_source_raw.as_str(), "response" | "request") {
|
||||
default_pricing_source_raw
|
||||
} else {
|
||||
log::warn!(
|
||||
"[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}"
|
||||
);
|
||||
"response".to_string()
|
||||
};
|
||||
|
||||
let provider = self
|
||||
.db
|
||||
.get_provider_by_id(provider_id, app_type)
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let (provider_multiplier, provider_pricing_source) = provider
|
||||
.as_ref()
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.map(|meta| {
|
||||
(
|
||||
meta.cost_multiplier.as_deref(),
|
||||
meta.pricing_model_source.as_deref(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
let cost_multiplier = match provider_multiplier {
|
||||
Some(value) => match Decimal::from_str(value) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[USG-003] 供应商倍率解析失败 (provider_id={provider_id}): {value} - {e}"
|
||||
);
|
||||
default_multiplier
|
||||
}
|
||||
},
|
||||
None => default_multiplier,
|
||||
};
|
||||
|
||||
let pricing_model_source = match provider_pricing_source {
|
||||
Some(value) if matches!(value, "response" | "request") => value.to_string(),
|
||||
Some(value) => {
|
||||
log::warn!("[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}");
|
||||
default_pricing_source.clone()
|
||||
}
|
||||
None => default_pricing_source.clone(),
|
||||
};
|
||||
|
||||
(cost_multiplier, pricing_model_source)
|
||||
}
|
||||
|
||||
/// 计算并记录请求
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn log_with_calculation(
|
||||
@@ -290,8 +202,6 @@ impl<'a> UsageLogger<'a> {
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
model: String,
|
||||
request_model: String,
|
||||
pricing_model: String,
|
||||
usage: TokenUsage,
|
||||
cost_multiplier: Decimal,
|
||||
latency_ms: u64,
|
||||
@@ -301,10 +211,10 @@ impl<'a> UsageLogger<'a> {
|
||||
provider_type: Option<String>,
|
||||
is_streaming: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let pricing = self.get_model_pricing(&pricing_model)?;
|
||||
let pricing = self.get_model_pricing(&model)?;
|
||||
|
||||
if pricing.is_none() {
|
||||
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
|
||||
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0");
|
||||
}
|
||||
|
||||
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||
@@ -314,7 +224,6 @@ impl<'a> UsageLogger<'a> {
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
usage,
|
||||
cost,
|
||||
latency_ms,
|
||||
@@ -365,8 +274,6 @@ mod tests {
|
||||
"provider-1".to_string(),
|
||||
"claude".to_string(),
|
||||
"test-model".to_string(),
|
||||
"req-model".to_string(),
|
||||
"test-model".to_string(),
|
||||
usage,
|
||||
Decimal::from(1),
|
||||
100,
|
||||
@@ -379,15 +286,14 @@ mod tests {
|
||||
|
||||
// 验证记录已插入
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, request_model): (i64, String) = conn
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*), request_model FROM proxy_request_logs WHERE request_id = 'req-123'",
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(request_model, "req-model");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -36,22 +36,10 @@ impl PromptService {
|
||||
|
||||
state.db.save_prompt(app.as_str(), &prompt)?;
|
||||
|
||||
// 如果是已启用的提示词,同步更新到对应的文件
|
||||
if is_enabled {
|
||||
// 启用提示词:写入内容到文件
|
||||
let target_path = prompt_file_path(&app)?;
|
||||
write_text_file(&target_path, &prompt.content)?;
|
||||
} else {
|
||||
// 禁用提示词:检查是否还有其他已启用的提示词
|
||||
let prompts = state.db.get_prompts(app.as_str())?;
|
||||
let any_enabled = prompts.values().any(|p| p.enabled);
|
||||
|
||||
if !any_enabled {
|
||||
// 所有提示词都已禁用,清空文件
|
||||
let target_path = prompt_file_path(&app)?;
|
||||
if target_path.exists() {
|
||||
write_text_file(&target_path, "")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -182,67 +182,33 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync all providers to live configuration (for additive mode apps)
|
||||
///
|
||||
/// Writes all providers from the database to the live configuration file.
|
||||
/// Used for OpenCode and other additive mode applications.
|
||||
fn sync_all_providers_to_live(state: &AppState, app_type: &AppType) -> Result<(), AppError> {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for provider in providers.values() {
|
||||
if let Err(e) = write_live_snapshot(app_type, provider) {
|
||||
log::warn!(
|
||||
"Failed to sync {:?} provider '{}' to live: {e}",
|
||||
app_type,
|
||||
provider.id
|
||||
);
|
||||
// Continue syncing other providers, don't abort
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Synced {} {:?} providers to live config",
|
||||
providers.len(),
|
||||
app_type
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration
|
||||
///
|
||||
/// 使用有效的当前供应商 ID(验证过存在性)。
|
||||
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
|
||||
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
|
||||
///
|
||||
/// For additive mode apps (OpenCode), all providers are synced instead of just the current one.
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
// Sync providers based on mode
|
||||
for app_type in AppType::all() {
|
||||
if app_type.is_additive_mode() {
|
||||
// Additive mode: sync ALL providers
|
||||
sync_all_providers_to_live(state, &app_type)?;
|
||||
} else {
|
||||
// Switch mode: sync only current provider
|
||||
let current_id =
|
||||
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
// Use validated effective current provider
|
||||
let current_id =
|
||||
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
}
|
||||
// Note: get_effective_current_provider already validates existence,
|
||||
// so providers.get() should always succeed here
|
||||
}
|
||||
|
||||
// MCP sync
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
// Skill sync
|
||||
for app_type in AppType::all() {
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
|
||||
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
|
||||
// Continue syncing other apps, don't abort
|
||||
|
||||
@@ -1530,10 +1530,7 @@ impl ProxyService {
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// 代理模式切换供应商(热切换):
|
||||
// - 更新 SSOT(数据库 is_current)
|
||||
// - 同步本地 settings(设备级 current_provider_*)
|
||||
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
|
||||
// 更新数据库中的 is_current 标记
|
||||
let app_type_enum =
|
||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
||||
|
||||
@@ -1541,35 +1538,6 @@ impl ProxyService {
|
||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
||||
|
||||
// 同步本地 settings(设备级优先)
|
||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
|
||||
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
|
||||
|
||||
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
|
||||
let has_backup = self
|
||||
.db
|
||||
.get_live_backup(app_type_enum.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
|
||||
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
||||
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
|
||||
if has_backup || live_taken_over {
|
||||
self.update_live_backup_from_provider(app_type, &provider)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -2090,66 +2058,4 @@ model = "gpt-5.1-codex"
|
||||
"should not add ANTHROPIC_AUTH_TOKEN when absent"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn switch_proxy_target_updates_live_backup_when_taken_over() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "a-key"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "b-key"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("claude", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("claude", "a")
|
||||
.expect("set current provider");
|
||||
|
||||
// 模拟“已接管”状态:存在 Live 备份(内容不重要,会被热切换更新)
|
||||
db.save_live_backup("claude", "{\"env\":{}}")
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
|
||||
service
|
||||
.switch_proxy_target("claude", "b")
|
||||
.await
|
||||
.expect("switch proxy target");
|
||||
|
||||
// 断言:本地 settings 的 current provider 已同步
|
||||
assert_eq!(
|
||||
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
|
||||
Some("b")
|
||||
);
|
||||
|
||||
// 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)
|
||||
let backup = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
|
||||
assert_eq!(backup.original_config, expected);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-164
@@ -21,19 +21,6 @@ use crate::error::format_skill_error;
|
||||
|
||||
// ========== 数据结构 ==========
|
||||
|
||||
/// Skill 同步方式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SyncMethod {
|
||||
/// 自动选择:优先 symlink,失败时回退到 copy
|
||||
#[default]
|
||||
Auto,
|
||||
/// 符号链接(推荐,节省磁盘空间)
|
||||
Symlink,
|
||||
/// 文件复制(兼容模式)
|
||||
Copy,
|
||||
}
|
||||
|
||||
/// 可发现的技能(来自仓库)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoverableSkill {
|
||||
@@ -141,12 +128,6 @@ impl Default for SkillStore {
|
||||
branch: "master".to_string(),
|
||||
enabled: true,
|
||||
},
|
||||
SkillRepo {
|
||||
owner: "JimLiu".to_string(),
|
||||
name: "baoyu-skills".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -252,50 +233,6 @@ impl SkillService {
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| skill.directory.clone());
|
||||
|
||||
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
||||
let existing_skills = db.get_all_installed_skills()?;
|
||||
for existing in existing_skills.values() {
|
||||
if existing.directory.eq_ignore_ascii_case(&install_name) {
|
||||
// 检查是否来自同一仓库
|
||||
let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)
|
||||
&& existing.repo_name.as_deref() == Some(&skill.repo_name);
|
||||
if same_repo {
|
||||
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
|
||||
let mut updated = existing.clone();
|
||||
updated.apps.set_enabled_for(current_app, true);
|
||||
db.save_skill(&updated)?;
|
||||
Self::sync_to_app_dir(&updated.directory, current_app)?;
|
||||
log::info!(
|
||||
"Skill {} 已存在,更新 {:?} 启用状态",
|
||||
updated.name,
|
||||
current_app
|
||||
);
|
||||
return Ok(updated);
|
||||
} else {
|
||||
// 不同仓库的同名 skill,报错
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"SKILL_DIRECTORY_CONFLICT",
|
||||
&[
|
||||
("directory", &install_name),
|
||||
(
|
||||
"existing_repo",
|
||||
&format!(
|
||||
"{}/{}",
|
||||
existing.repo_owner.as_deref().unwrap_or("unknown"),
|
||||
existing.repo_name.as_deref().unwrap_or("unknown")
|
||||
)
|
||||
),
|
||||
(
|
||||
"new_repo",
|
||||
&format!("{}/{}", skill.repo_owner, skill.repo_name)
|
||||
),
|
||||
],
|
||||
Some("uninstallFirst"),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dest = ssot_dir.join(&install_name);
|
||||
|
||||
// 如果已存在则跳过下载
|
||||
@@ -362,7 +299,7 @@ impl SkillService {
|
||||
db.save_skill(&installed_skill)?;
|
||||
|
||||
// 同步到当前应用目录
|
||||
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||
Self::copy_to_app(&install_name, current_app)?;
|
||||
|
||||
log::info!(
|
||||
"Skill {} 安装成功,已启用 {:?}",
|
||||
@@ -425,7 +362,7 @@ impl SkillService {
|
||||
|
||||
// 同步文件
|
||||
if enabled {
|
||||
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||
Self::copy_to_app(&skill.directory, app)?;
|
||||
} else {
|
||||
Self::remove_from_app(&skill.directory, app)?;
|
||||
}
|
||||
@@ -623,41 +560,8 @@ impl SkillService {
|
||||
|
||||
// ========== 文件同步方法 ==========
|
||||
|
||||
/// 创建符号链接(跨平台)
|
||||
///
|
||||
/// - Unix: 使用 std::os::unix::fs::symlink
|
||||
/// - Windows: 使用 std::os::windows::fs::symlink_dir
|
||||
#[cfg(unix)]
|
||||
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||
std::os::unix::fs::symlink(src, dest)
|
||||
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||
std::os::windows::fs::symlink_dir(src, dest)
|
||||
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||
}
|
||||
|
||||
/// 检查路径是否为符号链接
|
||||
fn is_symlink(path: &Path) -> bool {
|
||||
path.symlink_metadata()
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 获取当前同步方式配置
|
||||
fn get_sync_method() -> SyncMethod {
|
||||
crate::settings::get_skill_sync_method()
|
||||
}
|
||||
|
||||
/// 同步 Skill 到应用目录(使用 symlink 或 copy)
|
||||
///
|
||||
/// 根据配置和平台选择最佳同步方式:
|
||||
/// - Auto: 优先尝试 symlink,失败时回退到 copy
|
||||
/// - Symlink: 仅使用 symlink
|
||||
/// - Copy: 仅使用文件复制
|
||||
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
|
||||
/// 复制 Skill 到应用目录
|
||||
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let source = ssot_dir.join(directory);
|
||||
|
||||
@@ -670,77 +574,25 @@ impl SkillService {
|
||||
|
||||
let dest = app_dir.join(directory);
|
||||
|
||||
// 如果已存在则先删除(无论是 symlink 还是真实目录)
|
||||
if dest.exists() || Self::is_symlink(&dest) {
|
||||
Self::remove_path(&dest)?;
|
||||
// 如果已存在则先删除
|
||||
if dest.exists() {
|
||||
fs::remove_dir_all(&dest)?;
|
||||
}
|
||||
|
||||
let sync_method = Self::get_sync_method();
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
|
||||
match sync_method {
|
||||
SyncMethod::Auto => {
|
||||
// 优先尝试 symlink
|
||||
match Self::create_symlink(&source, &dest) {
|
||||
Ok(()) => {
|
||||
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err:#}",
|
||||
source.display(),
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fallback 到 copy
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||
}
|
||||
SyncMethod::Symlink => {
|
||||
Self::create_symlink(&source, &dest)?;
|
||||
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||
}
|
||||
SyncMethod::Copy => {
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||
}
|
||||
}
|
||||
log::debug!("Skill {directory} 已复制到 {app:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 复制 Skill 到应用目录(保留用于向后兼容)
|
||||
#[deprecated(note = "请使用 sync_to_app_dir() 代替")]
|
||||
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
||||
Self::sync_to_app_dir(directory, app)
|
||||
}
|
||||
|
||||
/// 删除路径(支持 symlink 和真实目录)
|
||||
fn remove_path(path: &Path) -> Result<()> {
|
||||
if Self::is_symlink(path) {
|
||||
// 符号链接:仅删除链接本身,不影响源文件
|
||||
#[cfg(unix)]
|
||||
fs::remove_file(path)?;
|
||||
#[cfg(windows)]
|
||||
fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir
|
||||
} else if path.is_dir() {
|
||||
// 真实目录:递归删除
|
||||
fs::remove_dir_all(path)?;
|
||||
} else if path.exists() {
|
||||
// 普通文件
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||||
/// 从应用目录删除 Skill
|
||||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||||
let app_dir = Self::get_app_skills_dir(app)?;
|
||||
let skill_path = app_dir.join(directory);
|
||||
|
||||
if skill_path.exists() || Self::is_symlink(&skill_path) {
|
||||
Self::remove_path(&skill_path)?;
|
||||
if skill_path.exists() {
|
||||
fs::remove_dir_all(&skill_path)?;
|
||||
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
||||
}
|
||||
|
||||
@@ -753,7 +605,7 @@ impl SkillService {
|
||||
|
||||
for skill in skills.values() {
|
||||
if skill.apps.is_enabled_for(app) {
|
||||
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||
Self::copy_to_app(&skill.directory, app)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,12 +829,10 @@ impl SkillService {
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||
/// 去重技能列表
|
||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||
let mut seen = HashMap::new();
|
||||
skills.retain(|skill| {
|
||||
// 使用完整 key(owner/repo:directory)作为唯一标识
|
||||
// 这样不同仓库的同名 skill 会分开显示
|
||||
let unique_key = skill.key.to_lowercase();
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
||||
e.insert(true);
|
||||
|
||||
@@ -94,9 +94,6 @@ pub struct RequestLogDetail {
|
||||
pub provider_name: Option<String>,
|
||||
pub app_type: String,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request_model: Option<String>,
|
||||
pub cost_multiplier: String,
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub cache_read_tokens: u32,
|
||||
@@ -143,7 +140,7 @@ impl Database {
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
"SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
@@ -221,7 +218,7 @@ impl Database {
|
||||
}
|
||||
|
||||
let sql = "
|
||||
SELECT
|
||||
SELECT
|
||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
@@ -298,7 +295,7 @@ impl Database {
|
||||
pub fn get_provider_stats(&self) -> Result<Vec<ProviderStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let sql = "SELECT
|
||||
let sql = "SELECT
|
||||
l.provider_id,
|
||||
p.name as provider_name,
|
||||
COUNT(*) as request_count,
|
||||
@@ -346,7 +343,7 @@ impl Database {
|
||||
pub fn get_model_stats(&self) -> Result<Vec<ModelStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let sql = "SELECT
|
||||
let sql = "SELECT
|
||||
model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
@@ -427,7 +424,7 @@ impl Database {
|
||||
|
||||
// 获取总数
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs l
|
||||
"SELECT COUNT(*) FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
{where_clause}"
|
||||
);
|
||||
@@ -443,7 +440,6 @@ impl Database {
|
||||
|
||||
let sql = format!(
|
||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||
l.request_model, l.cost_multiplier,
|
||||
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
|
||||
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
|
||||
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
|
||||
@@ -464,26 +460,22 @@ impl Database {
|
||||
provider_name: row.get(2)?,
|
||||
app_type: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
request_model: row.get(5)?,
|
||||
cost_multiplier: row
|
||||
.get::<_, Option<String>>(6)?
|
||||
.unwrap_or_else(|| "1".to_string()),
|
||||
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||
input_cost_usd: row.get(11)?,
|
||||
output_cost_usd: row.get(12)?,
|
||||
cache_read_cost_usd: row.get(13)?,
|
||||
cache_creation_cost_usd: row.get(14)?,
|
||||
total_cost_usd: row.get(15)?,
|
||||
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(20)? as u16,
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
input_tokens: row.get::<_, i64>(5)? as u32,
|
||||
output_tokens: row.get::<_, i64>(6)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(7)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
|
||||
input_cost_usd: row.get(9)?,
|
||||
output_cost_usd: row.get(10)?,
|
||||
cache_read_cost_usd: row.get(11)?,
|
||||
cache_creation_cost_usd: row.get(12)?,
|
||||
total_cost_usd: row.get(13)?,
|
||||
is_streaming: row.get::<_, i64>(14)? != 0,
|
||||
latency_ms: row.get::<_, i64>(15)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(18)? as u16,
|
||||
error_message: row.get(19)?,
|
||||
created_at: row.get(20)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -519,7 +511,6 @@ impl Database {
|
||||
|
||||
let result = conn.query_row(
|
||||
"SELECT l.request_id, l.provider_id, p.name as provider_name, l.app_type, l.model,
|
||||
l.request_model, l.cost_multiplier,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
is_streaming, latency_ms, first_token_ms, duration_ms,
|
||||
@@ -535,24 +526,22 @@ impl Database {
|
||||
provider_name: row.get(2)?,
|
||||
app_type: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
request_model: row.get(5)?,
|
||||
cost_multiplier: row.get::<_, Option<String>>(6)?.unwrap_or_else(|| "1".to_string()),
|
||||
input_tokens: row.get::<_, i64>(7)? as u32,
|
||||
output_tokens: row.get::<_, i64>(8)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(9)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(10)? as u32,
|
||||
input_cost_usd: row.get(11)?,
|
||||
output_cost_usd: row.get(12)?,
|
||||
cache_read_cost_usd: row.get(13)?,
|
||||
cache_creation_cost_usd: row.get(14)?,
|
||||
total_cost_usd: row.get(15)?,
|
||||
is_streaming: row.get::<_, i64>(16)? != 0,
|
||||
latency_ms: row.get::<_, i64>(17)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(18)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(19)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(20)? as u16,
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
input_tokens: row.get::<_, i64>(5)? as u32,
|
||||
output_tokens: row.get::<_, i64>(6)? as u32,
|
||||
cache_read_tokens: row.get::<_, i64>(7)? as u32,
|
||||
cache_creation_tokens: row.get::<_, i64>(8)? as u32,
|
||||
input_cost_usd: row.get(9)?,
|
||||
output_cost_usd: row.get(10)?,
|
||||
cache_read_cost_usd: row.get(11)?,
|
||||
cache_creation_cost_usd: row.get(12)?,
|
||||
total_cost_usd: row.get(13)?,
|
||||
is_streaming: row.get::<_, i64>(14)? != 0,
|
||||
latency_ms: row.get::<_, i64>(15)? as u64,
|
||||
first_token_ms: row.get::<_, Option<i64>>(16)?.map(|v| v as u64),
|
||||
duration_ms: row.get::<_, Option<i64>>(17)?.map(|v| v as u64),
|
||||
status_code: row.get::<_, i64>(18)? as u16,
|
||||
error_message: row.get(19)?,
|
||||
created_at: row.get(20)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -702,26 +691,21 @@ impl Database {
|
||||
)?;
|
||||
|
||||
let million = rust_decimal::Decimal::from(1_000_000u64);
|
||||
|
||||
// 与 CostCalculator::calculate 保持一致的计算逻辑:
|
||||
// 1. input_cost 需要扣除 cache_read_tokens(避免缓存部分被重复计费)
|
||||
// 2. 各项成本是基础成本(不含倍率)
|
||||
// 3. 倍率只作用于最终总价
|
||||
let billable_input_tokens =
|
||||
(log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64);
|
||||
let input_cost =
|
||||
rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million;
|
||||
let output_cost =
|
||||
rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output / million;
|
||||
let input_cost = rust_decimal::Decimal::from(log.input_tokens as u64) * pricing.input
|
||||
/ million
|
||||
* multiplier;
|
||||
let output_cost = rust_decimal::Decimal::from(log.output_tokens as u64) * pricing.output
|
||||
/ million
|
||||
* multiplier;
|
||||
let cache_read_cost = rust_decimal::Decimal::from(log.cache_read_tokens as u64)
|
||||
* pricing.cache_read
|
||||
/ million;
|
||||
/ million
|
||||
* multiplier;
|
||||
let cache_creation_cost = rust_decimal::Decimal::from(log.cache_creation_tokens as u64)
|
||||
* pricing.cache_creation
|
||||
/ million;
|
||||
// 总成本 = 基础成本之和 × 倍率
|
||||
let base_total = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||
let total_cost = base_total * multiplier;
|
||||
/ million
|
||||
* multiplier;
|
||||
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||
|
||||
log.input_cost_usd = format!("{input_cost:.6}");
|
||||
log.output_cost_usd = format!("{output_cost:.6}");
|
||||
|
||||
+7
-100
@@ -5,7 +5,6 @@ use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::SyncMethod;
|
||||
|
||||
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -17,47 +16,6 @@ pub struct CustomEndpoint {
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 主页面显示的应用配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VisibleApps {
|
||||
#[serde(default = "default_true")]
|
||||
pub claude: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub codex: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub gemini: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub opencode: bool,
|
||||
}
|
||||
|
||||
impl Default for VisibleApps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VisibleApps {
|
||||
/// Check if the specified app is visible
|
||||
pub fn is_visible(&self, app: &AppType) -> bool {
|
||||
match app {
|
||||
AppType::Claude => self.claude,
|
||||
AppType::Codex => self.codex,
|
||||
AppType::Gemini => self.gemini,
|
||||
AppType::OpenCode => self.opencode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用设置结构
|
||||
///
|
||||
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
|
||||
@@ -74,21 +32,14 @@ pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub enable_claude_plugin_integration: bool,
|
||||
/// 是否跳过 Claude Code 初次安装确认
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_true")]
|
||||
pub skip_claude_onboarding: bool,
|
||||
/// 是否开机自启
|
||||
#[serde(default)]
|
||||
pub launch_on_startup: bool,
|
||||
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||||
#[serde(default)]
|
||||
pub silent_startup: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
|
||||
// ===== 主页面显示的应用 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visible_apps: Option<VisibleApps>,
|
||||
|
||||
// ===== 设备级目录覆盖 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_config_dir: Option<String>,
|
||||
@@ -112,19 +63,6 @@ pub struct AppSettings {
|
||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_opencode: Option<String>,
|
||||
|
||||
// ===== Skill 同步设置 =====
|
||||
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
#[serde(default)]
|
||||
pub skill_sync_method: SyncMethod,
|
||||
|
||||
// ===== 终端设置 =====
|
||||
/// 首选终端应用(可选,默认使用系统默认终端)
|
||||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
|
||||
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_terminal: Option<String>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
@@ -135,17 +73,19 @@ fn default_minimize_to_tray_on_close() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_in_tray: true,
|
||||
minimize_to_tray_on_close: true,
|
||||
enable_claude_plugin_integration: false,
|
||||
skip_claude_onboarding: false,
|
||||
skip_claude_onboarding: true,
|
||||
launch_on_startup: false,
|
||||
silent_startup: false,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
gemini_config_dir: None,
|
||||
@@ -154,8 +94,6 @@ impl Default for AppSettings {
|
||||
current_provider_codex: None,
|
||||
current_provider_gemini: None,
|
||||
current_provider_opencode: None,
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
preferred_terminal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,11 +101,7 @@ impl Default for AppSettings {
|
||||
impl AppSettings {
|
||||
fn settings_path() -> Option<PathBuf> {
|
||||
// settings.json 保留用于旧版本迁移和无数据库场景
|
||||
Some(
|
||||
crate::config::get_home_dir()
|
||||
.join(".cc-switch")
|
||||
.join("settings.json"),
|
||||
)
|
||||
dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
|
||||
}
|
||||
|
||||
fn normalize_paths(&mut self) {
|
||||
@@ -406,30 +340,3 @@ pub fn get_effective_current_provider(
|
||||
// Fallback 到数据库的 is_current
|
||||
db.get_current_provider(app_type.as_str())
|
||||
}
|
||||
|
||||
// ===== Skill 同步方式管理函数 =====
|
||||
|
||||
/// 获取 Skill 同步方式配置
|
||||
pub fn get_skill_sync_method() -> SyncMethod {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.skill_sync_method
|
||||
}
|
||||
|
||||
// ===== 终端设置管理函数 =====
|
||||
|
||||
/// 获取首选终端应用
|
||||
pub fn get_preferred_terminal() -> Option<String> {
|
||||
settings_store()
|
||||
.read()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||
e.into_inner()
|
||||
})
|
||||
.preferred_terminal
|
||||
.clone()
|
||||
}
|
||||
|
||||
+1
-43
@@ -211,28 +211,6 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A
|
||||
if let Some(app_state) = app.try_state::<AppState>() {
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
// 强一致语义:Auto 模式开启后立即切到队列 P1(P1→P2→...)
|
||||
// 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户陷入无法开启的死锁。
|
||||
let mut queue = app_state.db.get_failover_queue(app_type_str)?;
|
||||
if queue.is_empty() {
|
||||
let current_id =
|
||||
crate::settings::get_effective_current_provider(&app_state.db, app_type)?;
|
||||
let Some(current_id) = current_id else {
|
||||
return Err(AppError::Message(
|
||||
"故障转移队列为空,且未设置当前供应商,无法启用 Auto 模式".to_string(),
|
||||
));
|
||||
};
|
||||
app_state
|
||||
.db
|
||||
.add_to_failover_queue(app_type_str, ¤t_id)?;
|
||||
queue = app_state.db.get_failover_queue(app_type_str)?;
|
||||
}
|
||||
|
||||
let p1_provider_id = queue
|
||||
.first()
|
||||
.map(|item| item.provider_id.clone())
|
||||
.ok_or_else(|| AppError::Message("故障转移队列为空,无法启用 Auto 模式".to_string()))?;
|
||||
|
||||
// 真正启用 failover:启动代理服务 + 执行接管 + 开启 auto_failover
|
||||
let proxy_service = &app_state.proxy_service;
|
||||
|
||||
@@ -260,16 +238,6 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A
|
||||
.db
|
||||
.set_proxy_flags_sync(app_type_str, true, true)?;
|
||||
|
||||
// 3.1) 立即切到队列 P1(热切换:不写 Live,仅更新 DB/settings/备份)
|
||||
if let Err(e) = futures::executor::block_on(
|
||||
proxy_service.switch_proxy_target(app_type_str, &p1_provider_id),
|
||||
) {
|
||||
log::error!("[Tray] Auto 模式切换到队列 P1 失败: {e}");
|
||||
return Err(AppError::Message(format!(
|
||||
"Auto 模式切换到队列 P1 失败: {e}"
|
||||
)));
|
||||
}
|
||||
|
||||
// 4) 更新托盘菜单
|
||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
@@ -281,8 +249,7 @@ fn handle_auto_click(app: &tauri::AppHandle, app_type: &AppType) -> Result<(), A
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"proxyEnabled": true,
|
||||
"autoFailoverEnabled": true,
|
||||
"providerId": p1_provider_id
|
||||
"autoFailoverEnabled": true
|
||||
});
|
||||
if let Err(e) = app.emit("proxy-flags-changed", event_data.clone()) {
|
||||
log::error!("发射 proxy-flags-changed 事件失败: {e}");
|
||||
@@ -351,9 +318,6 @@ pub fn create_tray_menu(
|
||||
let app_settings = crate::settings::get_settings();
|
||||
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||
|
||||
// Get visible apps setting, default to all visible
|
||||
let visible_apps = app_settings.visible_apps.unwrap_or_default();
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
|
||||
// 顶部:打开主界面
|
||||
@@ -363,13 +327,7 @@ pub fn create_tray_menu(
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
// Only add visible app sections
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
// Skip hidden apps
|
||||
if !visible_apps.is_visible(§ion.app_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let app_type_str = section.app_type.as_str();
|
||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.10.2",
|
||||
"version": "3.9.1",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -971,18 +971,12 @@ fn export_sql_returns_error_for_invalid_path() {
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// Try to export to an invalid path (nonexistent parent or invalid name on Windows)
|
||||
let invalid_parent = if cfg!(windows) {
|
||||
std::env::temp_dir().join("cc-switch-test-invalid<>dir")
|
||||
} else {
|
||||
PathBuf::from("/nonexistent/directory")
|
||||
};
|
||||
let invalid_path = invalid_parent.join("export.sql");
|
||||
// Try to export to an invalid path (parent directory doesn't exist)
|
||||
let invalid_path = PathBuf::from("/nonexistent/directory/export.sql");
|
||||
let err = state
|
||||
.db
|
||||
.export_sql(&invalid_path)
|
||||
.expect_err("export to invalid path should fail");
|
||||
let invalid_prefix = invalid_parent.to_string_lossy();
|
||||
|
||||
// The error can be either IoContext or Io depending on where it fails
|
||||
match err {
|
||||
@@ -994,8 +988,8 @@ fn export_sql_returns_error_for_invalid_path() {
|
||||
}
|
||||
AppError::Io { path, .. } => {
|
||||
assert!(
|
||||
path.starts_with(invalid_prefix.as_ref()),
|
||||
"expected error for {invalid_parent:?}, got: {path:?}"
|
||||
path.starts_with("/nonexistent"),
|
||||
"expected error for /nonexistent path, got: {path:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected IoContext or Io error, got {other:?}"),
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
use cc_switch_lib::{
|
||||
get_default_cost_multiplier_test_hook, get_pricing_model_source_test_hook,
|
||||
set_default_cost_multiplier_test_hook, set_pricing_model_source_test_hook, AppError,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||
|
||||
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn default_cost_multiplier_commands_round_trip() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
let default = get_default_cost_multiplier_test_hook(&state, "claude")
|
||||
.await
|
||||
.expect("read default multiplier");
|
||||
assert_eq!(default, "1");
|
||||
|
||||
set_default_cost_multiplier_test_hook(&state, "claude", "1.5")
|
||||
.await
|
||||
.expect("set multiplier");
|
||||
let updated = get_default_cost_multiplier_test_hook(&state, "claude")
|
||||
.await
|
||||
.expect("read updated multiplier");
|
||||
assert_eq!(updated, "1.5");
|
||||
|
||||
let err = set_default_cost_multiplier_test_hook(&state, "claude", "not-a-number")
|
||||
.await
|
||||
.expect_err("invalid multiplier should error");
|
||||
// 错误已改为 Localized 类型(支持 i18n)
|
||||
match err {
|
||||
AppError::Localized { key, .. } => {
|
||||
assert_eq!(key, "error.invalidMultiplier");
|
||||
}
|
||||
other => panic!("expected localized error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 测试使用 Mutex 进行串行化,跨 await 持锁是预期行为
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn pricing_model_source_commands_round_trip() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
let default = get_pricing_model_source_test_hook(&state, "claude")
|
||||
.await
|
||||
.expect("read default pricing model source");
|
||||
assert_eq!(default, "response");
|
||||
|
||||
set_pricing_model_source_test_hook(&state, "claude", "request")
|
||||
.await
|
||||
.expect("set pricing model source");
|
||||
let updated = get_pricing_model_source_test_hook(&state, "claude")
|
||||
.await
|
||||
.expect("read updated pricing model source");
|
||||
assert_eq!(updated, "request");
|
||||
|
||||
let err = set_pricing_model_source_test_hook(&state, "claude", "invalid")
|
||||
.await
|
||||
.expect_err("invalid pricing model source should error");
|
||||
// 错误已改为 Localized 类型(支持 i18n)
|
||||
match err {
|
||||
AppError::Localized { key, .. } => {
|
||||
assert_eq!(key, "error.invalidPricingMode");
|
||||
}
|
||||
other => panic!("expected localized error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
+18
-73
@@ -11,14 +11,15 @@ import {
|
||||
// Bot, // TODO: Agents 功能开发中,暂时不需要
|
||||
Book,
|
||||
Wrench,
|
||||
Server,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Download,
|
||||
BarChart2,
|
||||
} from "lucide-react";
|
||||
import type { Provider, VisibleApps } from "@/types";
|
||||
import type { Provider } from "@/types";
|
||||
import type { EnvConflict } from "@/types/env";
|
||||
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
|
||||
import { useProvidersQuery } from "@/lib/query";
|
||||
import {
|
||||
providersApi,
|
||||
settingsApi,
|
||||
@@ -30,7 +31,6 @@ import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isWindows, isLinux } from "@/lib/platform";
|
||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||
@@ -51,7 +51,6 @@ import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { McpIcon } from "@/components/BrandIcons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type View =
|
||||
@@ -78,31 +77,6 @@ function App() {
|
||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
|
||||
// Get settings for visibleApps
|
||||
const { data: settingsData } = useSettingsQuery();
|
||||
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
opencode: true,
|
||||
};
|
||||
|
||||
// Get first visible app for fallback
|
||||
const getFirstVisibleApp = (): AppId => {
|
||||
if (visibleApps.claude) return "claude";
|
||||
if (visibleApps.codex) return "codex";
|
||||
if (visibleApps.gemini) return "gemini";
|
||||
if (visibleApps.opencode) return "opencode";
|
||||
return "claude"; // fallback
|
||||
};
|
||||
|
||||
// If current active app is hidden, switch to first visible app
|
||||
useEffect(() => {
|
||||
if (!visibleApps[activeApp]) {
|
||||
setActiveApp(getFirstVisibleApp());
|
||||
}
|
||||
}, [visibleApps, activeApp]);
|
||||
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||
// Confirm action state: 'remove' = remove from live config, 'delete' = delete from database
|
||||
@@ -319,40 +293,18 @@ function App() {
|
||||
checkEnvOnSwitch();
|
||||
}, [activeApp]);
|
||||
|
||||
// 全局键盘快捷键
|
||||
const currentViewRef = useRef(currentView);
|
||||
|
||||
useEffect(() => {
|
||||
currentViewRef.current = currentView;
|
||||
}, [currentView]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Cmd/Ctrl + , 打开设置
|
||||
if (event.key === "," && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
setCurrentView("settings");
|
||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC 键返回
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
|
||||
// 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理
|
||||
if (document.body.style.overflow === "hidden") return;
|
||||
|
||||
const view = currentViewRef.current;
|
||||
if (view === "providers") return;
|
||||
|
||||
if (isTextEditableTarget(event.target)) return;
|
||||
|
||||
event.preventDefault();
|
||||
setCurrentView(view === "skillsDiscovery" ? "skills" : "providers");
|
||||
setCurrentView("settings");
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keydown", handleGlobalShortcut);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keydown", handleGlobalShortcut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -696,7 +648,7 @@ function App() {
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex h-full items-center justify-between gap-2 px-6"
|
||||
className="mx-auto flex h-full max-w-[56rem] items-center justify-between gap-2 px-4"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
>
|
||||
@@ -750,6 +702,13 @@ function App() {
|
||||
>
|
||||
CC Switch
|
||||
</a>
|
||||
<UpdateBadge
|
||||
onClick={() => {
|
||||
setSettingsDefaultTab("about");
|
||||
setCurrentView("settings");
|
||||
}}
|
||||
className="absolute -top-4 -right-4"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -763,12 +722,6 @@ function App() {
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
<UpdateBadge
|
||||
onClick={() => {
|
||||
setSettingsDefaultTab("about");
|
||||
setCurrentView("settings");
|
||||
}}
|
||||
/>
|
||||
{isCurrentAppTakeoverActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -888,15 +841,7 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppSwitcher
|
||||
activeApp={activeApp}
|
||||
onSwitch={setActiveApp}
|
||||
visibleApps={visibleApps}
|
||||
compact={
|
||||
isCurrentAppTakeoverActive &&
|
||||
Object.values(visibleApps).filter(Boolean).length >= 4
|
||||
}
|
||||
/>
|
||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<Button
|
||||
@@ -942,7 +887,7 @@ function App() {
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
<Server className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import type { AppId } from "@/lib/api";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppId;
|
||||
onSwitch: (app: AppId) => void;
|
||||
visibleApps?: VisibleApps;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode"];
|
||||
|
||||
export function AppSwitcher({
|
||||
activeApp,
|
||||
onSwitch,
|
||||
visibleApps,
|
||||
compact,
|
||||
}: AppSwitcherProps) {
|
||||
export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
const handleSwitch = (app: AppId) => {
|
||||
if (app === activeApp) return;
|
||||
onSwitch(app);
|
||||
@@ -35,33 +25,95 @@ export function AppSwitcher({
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
|
||||
// Filter apps based on visibility settings (default all visible)
|
||||
const appsToShow = ALL_APPS.filter((app) => {
|
||||
if (!visibleApps) return true;
|
||||
return visibleApps[app];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
|
||||
{appsToShow.map((app) => (
|
||||
<button
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(app)}
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === app
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName[app]}
|
||||
name={appDisplayName[app]}
|
||||
size={iconSize}
|
||||
/>
|
||||
{!compact && <span>{appDisplayName[app]}</span>}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("claude")}
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "claude"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName.claude}
|
||||
name={appDisplayName.claude}
|
||||
size={iconSize}
|
||||
className={
|
||||
activeApp === "claude"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>{appDisplayName.claude}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("codex")}
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "codex"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName.codex}
|
||||
name={appDisplayName.codex}
|
||||
size={iconSize}
|
||||
className={
|
||||
activeApp === "codex"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>{appDisplayName.codex}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("gemini")}
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "gemini"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName.gemini}
|
||||
name={appDisplayName.gemini}
|
||||
size={iconSize}
|
||||
className={
|
||||
activeApp === "gemini"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>{appDisplayName.gemini}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("opencode")}
|
||||
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "opencode"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName.opencode}
|
||||
name={appDisplayName.opencode}
|
||||
size={iconSize}
|
||||
className={
|
||||
activeApp === "opencode"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
}
|
||||
/>
|
||||
<span>{appDisplayName.opencode}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,21 +46,3 @@ export function GeminiIcon({ size = 16, className = "" }: IconProps) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// MCP icon uses inline SVG to support currentColor for hover effects
|
||||
export function McpIcon({ size = 16, className = "" }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
height={size}
|
||||
width={size}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
|
||||
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { getIcon, hasIcon, getIconMetadata } from "@/icons/extracted";
|
||||
import { getIcon, hasIcon } from "@/icons/extracted";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderIconProps {
|
||||
@@ -39,23 +39,6 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
||||
};
|
||||
}, [size]);
|
||||
|
||||
// 获取有效颜色:优先使用传入的有效 color,否则从元数据获取 defaultColor
|
||||
const effectiveColor = useMemo(() => {
|
||||
// 只有当 color 是有效的非空字符串时才使用
|
||||
if (color && typeof color === "string" && color.trim() !== "") {
|
||||
return color;
|
||||
}
|
||||
// 否则从元数据获取 defaultColor
|
||||
if (icon) {
|
||||
const metadata = getIconMetadata(icon);
|
||||
// 只有当 defaultColor 不是 currentColor 时才使用
|
||||
if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") {
|
||||
return metadata.defaultColor;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [color, icon]);
|
||||
|
||||
// 如果有图标,显示图标
|
||||
if (iconSvg) {
|
||||
return (
|
||||
@@ -64,7 +47,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
||||
"inline-flex items-center justify-center flex-shrink-0",
|
||||
className,
|
||||
)}
|
||||
style={{ ...sizeStyle, color: effectiveColor }}
|
||||
style={{ ...sizeStyle, color }}
|
||||
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useUpdate } from "@/contexts/UpdateContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowUpCircle } from "lucide-react";
|
||||
|
||||
interface UpdateBadgeProps {
|
||||
className?: string;
|
||||
@@ -31,12 +30,17 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
relative h-8 w-8 rounded-full
|
||||
${isActive ? "text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
||||
relative h-6 w-6 rounded-full
|
||||
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<ArrowUpCircle className="h-5 w-5" />
|
||||
<span
|
||||
className={`
|
||||
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
|
||||
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ interface AgentsPanelProps {
|
||||
|
||||
export function AgentsPanel({}: AgentsPanelProps) {
|
||||
return (
|
||||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)]">
|
||||
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
|
||||
<Bot className="w-10 h-10 text-muted-foreground" />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isWindows, isLinux } from "@/lib/platform";
|
||||
import { isTextEditableTarget } from "@/utils/domUtils";
|
||||
|
||||
interface FullScreenPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -38,39 +37,6 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// ESC 键关闭面板
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
// 子组件(例如 Radix 的 Select/Dialog/Dropdown)如果已经消费了 ESC,就不要再关闭整个面板
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextEditableTarget(event.target)) {
|
||||
return; // 让输入框自己处理 ESC(比如清空、失焦等)
|
||||
}
|
||||
|
||||
event.stopPropagation(); // 阻止事件继续冒泡到 window,避免触发 App.tsx 的全局监听
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用冒泡阶段监听,让子组件(如 Radix UI)优先处理 ESC
|
||||
window.addEventListener("keydown", handleKeyDown, false);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, false);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
|
||||
@@ -128,7 +128,6 @@ export function EditProviderDialog({
|
||||
iconColor: provider.iconColor,
|
||||
};
|
||||
}, [
|
||||
open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据
|
||||
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
|
||||
provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig
|
||||
initialSettingsConfig,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
Globe,
|
||||
Coins,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
@@ -14,31 +13,14 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
|
||||
|
||||
export type PricingModelSourceOption = "inherit" | "request" | "response";
|
||||
|
||||
interface ProviderPricingConfig {
|
||||
enabled: boolean;
|
||||
costMultiplier?: string;
|
||||
pricingModelSource: PricingModelSourceOption;
|
||||
}
|
||||
|
||||
interface ProviderAdvancedConfigProps {
|
||||
testConfig: ProviderTestConfig;
|
||||
proxyConfig: ProviderProxyConfig;
|
||||
pricingConfig: ProviderPricingConfig;
|
||||
onTestConfigChange: (config: ProviderTestConfig) => void;
|
||||
onProxyConfigChange: (config: ProviderProxyConfig) => void;
|
||||
onPricingConfigChange: (config: ProviderPricingConfig) => void;
|
||||
}
|
||||
|
||||
/** 从 ProviderProxyConfig 构建完整 URL */
|
||||
@@ -89,19 +71,14 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
||||
export function ProviderAdvancedConfig({
|
||||
testConfig,
|
||||
proxyConfig,
|
||||
pricingConfig,
|
||||
onTestConfigChange,
|
||||
onProxyConfigChange,
|
||||
onPricingConfigChange,
|
||||
}: ProviderAdvancedConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||
proxyConfig.enabled,
|
||||
);
|
||||
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(
|
||||
pricingConfig.enabled,
|
||||
);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
|
||||
@@ -120,11 +97,6 @@ export function ProviderAdvancedConfig({
|
||||
setIsProxyConfigOpen(proxyConfig.enabled);
|
||||
}, [proxyConfig.enabled]);
|
||||
|
||||
// 同步外部 pricingConfig.enabled 变化到展开状态
|
||||
useEffect(() => {
|
||||
setIsPricingConfigOpen(pricingConfig.enabled);
|
||||
}, [pricingConfig.enabled]);
|
||||
|
||||
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
|
||||
useEffect(() => {
|
||||
if (!isUserTyping) {
|
||||
@@ -478,143 +450,6 @@ export function ProviderAdvancedConfig({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费配置 */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setIsPricingConfigOpen(!isPricingConfigOpen)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Coins className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t("providerAdvanced.pricingConfig", {
|
||||
defaultValue: "计费配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Label
|
||||
htmlFor="pricing-config-enabled"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("providerAdvanced.useCustomPricing", {
|
||||
defaultValue: "使用单独配置",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
id="pricing-config-enabled"
|
||||
checked={pricingConfig.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onPricingConfigChange({ ...pricingConfig, enabled: checked });
|
||||
if (checked) setIsPricingConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isPricingConfigOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isPricingConfigOpen
|
||||
? "max-h-[500px] opacity-100"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="border-t border-border/50 p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("providerAdvanced.pricingConfigDesc", {
|
||||
defaultValue:
|
||||
"为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cost-multiplier">
|
||||
{t("providerAdvanced.costMultiplier", {
|
||||
defaultValue: "成本倍率",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="cost-multiplier"
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={pricingConfig.costMultiplier || ""}
|
||||
onChange={(e) =>
|
||||
onPricingConfigChange({
|
||||
...pricingConfig,
|
||||
costMultiplier: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("providerAdvanced.costMultiplierPlaceholder", {
|
||||
defaultValue: "留空使用全局默认(1)",
|
||||
})}
|
||||
disabled={!pricingConfig.enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerAdvanced.costMultiplierHint", {
|
||||
defaultValue: "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pricing-model-source">
|
||||
{t("providerAdvanced.pricingModelSourceLabel", {
|
||||
defaultValue: "计费模式",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={pricingConfig.pricingModelSource}
|
||||
onValueChange={(value) =>
|
||||
onPricingConfigChange({
|
||||
...pricingConfig,
|
||||
pricingModelSource: value as PricingModelSourceOption,
|
||||
})
|
||||
}
|
||||
disabled={!pricingConfig.enabled}
|
||||
>
|
||||
<SelectTrigger id="pricing-model-source">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">
|
||||
{t("providerAdvanced.pricingModelSourceInherit", {
|
||||
defaultValue: "继承全局默认",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="request">
|
||||
{t("providerAdvanced.pricingModelSourceRequest", {
|
||||
defaultValue: "请求模型",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="response">
|
||||
{t("providerAdvanced.pricingModelSourceResponse", {
|
||||
defaultValue: "返回模型",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerAdvanced.pricingModelSourceHint", {
|
||||
defaultValue: "选择按请求模型还是返回模型进行定价匹配",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,10 +46,7 @@ import { BasicFormFields } from "./BasicFormFields";
|
||||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||||
import { CodexFormFields } from "./CodexFormFields";
|
||||
import { GeminiFormFields } from "./GeminiFormFields";
|
||||
import {
|
||||
ProviderAdvancedConfig,
|
||||
type PricingModelSourceOption,
|
||||
} from "./ProviderAdvancedConfig";
|
||||
import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig";
|
||||
import {
|
||||
useProviderCategory,
|
||||
useApiKeyState,
|
||||
@@ -124,9 +121,6 @@ interface ProviderFormProps {
|
||||
showButtons?: boolean;
|
||||
}
|
||||
|
||||
const normalizePricingSource = (value?: string): PricingModelSourceOption =>
|
||||
value === "request" || value === "response" ? value : "inherit";
|
||||
|
||||
export function ProviderForm({
|
||||
appId,
|
||||
providerId,
|
||||
@@ -174,19 +168,6 @@ export function ProviderForm({
|
||||
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
|
||||
() => initialData?.meta?.proxyConfig ?? { enabled: false },
|
||||
);
|
||||
const [pricingConfig, setPricingConfig] = useState<{
|
||||
enabled: boolean;
|
||||
costMultiplier?: string;
|
||||
pricingModelSource: PricingModelSourceOption;
|
||||
}>(() => ({
|
||||
enabled:
|
||||
initialData?.meta?.costMultiplier !== undefined ||
|
||||
initialData?.meta?.pricingModelSource !== undefined,
|
||||
costMultiplier: initialData?.meta?.costMultiplier,
|
||||
pricingModelSource: normalizePricingSource(
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
}));
|
||||
|
||||
// 使用 category hook
|
||||
const { category } = useProviderCategory({
|
||||
@@ -207,15 +188,6 @@ export function ProviderForm({
|
||||
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
|
||||
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
|
||||
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
|
||||
setPricingConfig({
|
||||
enabled:
|
||||
initialData?.meta?.costMultiplier !== undefined ||
|
||||
initialData?.meta?.pricingModelSource !== undefined,
|
||||
costMultiplier: initialData?.meta?.costMultiplier,
|
||||
pricingModelSource: normalizePricingSource(
|
||||
initialData?.meta?.pricingModelSource,
|
||||
),
|
||||
});
|
||||
}, [appId, initialData]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -968,13 +940,6 @@ export function ProviderForm({
|
||||
// 添加高级配置
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
? pricingConfig.costMultiplier
|
||||
: undefined,
|
||||
pricingModelSource:
|
||||
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
|
||||
? pricingConfig.pricingModelSource
|
||||
: undefined,
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
@@ -1499,10 +1464,8 @@ export function ProviderForm({
|
||||
<ProviderAdvancedConfig
|
||||
testConfig={testConfig}
|
||||
proxyConfig={proxyConfig}
|
||||
pricingConfig={pricingConfig}
|
||||
onTestConfigChange={setTestConfig}
|
||||
onProxyConfigChange={setProxyConfig}
|
||||
onPricingConfigChange={setPricingConfig}
|
||||
/>
|
||||
|
||||
{showButtons && (
|
||||
|
||||
@@ -1,46 +1,10 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface UseModelStateProps {
|
||||
settingsConfig: string;
|
||||
onConfigChange: (config: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse model values from settings config JSON
|
||||
*/
|
||||
function parseModelsFromConfig(settingsConfig: string) {
|
||||
try {
|
||||
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||
const env = cfg?.env || {};
|
||||
const model =
|
||||
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
|
||||
const reasoning =
|
||||
typeof env.ANTHROPIC_REASONING_MODEL === "string"
|
||||
? env.ANTHROPIC_REASONING_MODEL
|
||||
: "";
|
||||
const small =
|
||||
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||
? env.ANTHROPIC_SMALL_FAST_MODEL
|
||||
: "";
|
||||
const haiku =
|
||||
typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
: small || model;
|
||||
const sonnet =
|
||||
typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
: model || small;
|
||||
const opus =
|
||||
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
: model || small;
|
||||
|
||||
return { model, reasoning, haiku, sonnet, opus };
|
||||
} catch {
|
||||
return { model: "", reasoning: "", haiku: "", sonnet: "", opus: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理模型选择状态
|
||||
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
|
||||
@@ -49,25 +13,11 @@ export function useModelState({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
}: UseModelStateProps) {
|
||||
// Initialize state by parsing config directly (fixes edit mode backfill)
|
||||
const [claudeModel, setClaudeModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).model,
|
||||
);
|
||||
const [reasoningModel, setReasoningModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).reasoning,
|
||||
);
|
||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).haiku,
|
||||
);
|
||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).sonnet,
|
||||
);
|
||||
const [defaultOpusModel, setDefaultOpusModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).opus,
|
||||
);
|
||||
|
||||
const isUserEditingRef = useRef(false);
|
||||
const lastConfigRef = useRef(settingsConfig);
|
||||
const [claudeModel, setClaudeModel] = useState("");
|
||||
const [reasoningModel, setReasoningModel] = useState("");
|
||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
|
||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
|
||||
const [defaultOpusModel, setDefaultOpusModel] = useState("");
|
||||
|
||||
// 初始化读取:读新键;若缺失,按兼容优先级回退
|
||||
// Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL
|
||||
@@ -75,18 +25,6 @@ export function useModelState({
|
||||
// Opus: DEFAULT_OPUS || MODEL || SMALL_FAST
|
||||
// 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时)
|
||||
useEffect(() => {
|
||||
if (lastConfigRef.current === settingsConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUserEditingRef.current) {
|
||||
isUserEditingRef.current = false;
|
||||
lastConfigRef.current = settingsConfig;
|
||||
return;
|
||||
}
|
||||
|
||||
lastConfigRef.current = settingsConfig;
|
||||
|
||||
try {
|
||||
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||
const env = cfg?.env || {};
|
||||
@@ -133,8 +71,6 @@ export function useModelState({
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
value: string,
|
||||
) => {
|
||||
isUserEditingRef.current = true;
|
||||
|
||||
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
|
||||
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
|
||||
@@ -140,8 +140,7 @@ export function FailoverQueueManager({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failover.autoSwitchDescription", {
|
||||
defaultValue:
|
||||
"开启后将立即切换到队列 P1,并在请求失败时自动切换到队列中的下一个供应商",
|
||||
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -39,11 +39,11 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
const tooltipText = isEnabled
|
||||
? t("failover.tooltip.enabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
|
||||
defaultValue: `${appLabel} 故障转移已启用\n自动切换到下一个可用供应商`,
|
||||
})
|
||||
: t("failover.tooltip.disabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
|
||||
defaultValue: `启用 ${appLabel} 故障转移\n当当前供应商失败时自动切换`,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -50,8 +50,8 @@ export function ProxyPanel() {
|
||||
}
|
||||
}, [globalConfig]);
|
||||
|
||||
// 获取所有三个应用类型的故障转移队列
|
||||
// 启用自动故障转移后,将按队列优先级(P1→P2→...)选择供应商
|
||||
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
|
||||
// 当前供应商始终优先,队列仅用于失败后的备用顺序
|
||||
const { data: claudeQueue = [] } = useFailoverQueue("claude");
|
||||
const { data: codexQueue = [] } = useFailoverQueue("codex");
|
||||
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
interface AppVisibilitySettingsProps {
|
||||
settings: SettingsFormState;
|
||||
onChange: (updates: Partial<SettingsFormState>) => void;
|
||||
}
|
||||
|
||||
const APP_CONFIG: Array<{
|
||||
id: AppId;
|
||||
icon: string;
|
||||
nameKey: string;
|
||||
}> = [
|
||||
{ id: "claude", icon: "claude", nameKey: "apps.claude" },
|
||||
{ id: "codex", icon: "openai", nameKey: "apps.codex" },
|
||||
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
|
||||
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
|
||||
];
|
||||
|
||||
export function AppVisibilitySettings({
|
||||
settings,
|
||||
onChange,
|
||||
}: AppVisibilitySettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const visibleApps: VisibleApps = settings.visibleApps ?? {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: false,
|
||||
opencode: true,
|
||||
};
|
||||
|
||||
// Count how many apps are currently visible
|
||||
const visibleCount = Object.values(visibleApps).filter(Boolean).length;
|
||||
|
||||
const handleToggle = (appId: AppId) => {
|
||||
const isCurrentlyVisible = visibleApps[appId];
|
||||
// Prevent disabling the last visible app
|
||||
if (isCurrentlyVisible && visibleCount <= 1) return;
|
||||
|
||||
onChange({
|
||||
visibleApps: {
|
||||
...visibleApps,
|
||||
[appId]: !isCurrentlyVisible,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("settings.appVisibility.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.appVisibility.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
||||
{APP_CONFIG.map((app) => {
|
||||
const isVisible = visibleApps[app.id];
|
||||
// Disable button if this is the last visible app
|
||||
const isDisabled = isVisible && visibleCount <= 1;
|
||||
|
||||
return (
|
||||
<AppButton
|
||||
key={app.id}
|
||||
active={isVisible}
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleToggle(app.id)}
|
||||
icon={app.icon}
|
||||
name={t(app.nameKey)}
|
||||
>
|
||||
{t(app.nameKey)}
|
||||
</AppButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppButtonProps {
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
icon: string;
|
||||
name: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function AppButton({
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
icon,
|
||||
name,
|
||||
children,
|
||||
}: AppButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"w-[90px] gap-1.5",
|
||||
active
|
||||
? "shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<ProviderIcon icon={icon} name={name} size={14} />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,6 @@ interface DirectorySettingsProps {
|
||||
claudeDir?: string;
|
||||
codexDir?: string;
|
||||
geminiDir?: string;
|
||||
opencodeDir?: string;
|
||||
onDirectoryChange: (app: AppId, value?: string) => void;
|
||||
onBrowseDirectory: (app: AppId) => Promise<void>;
|
||||
onResetDirectory: (app: AppId) => Promise<void>;
|
||||
@@ -30,7 +29,6 @@ export function DirectorySettings({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
geminiDir,
|
||||
opencodeDir,
|
||||
onDirectoryChange,
|
||||
onBrowseDirectory,
|
||||
onResetDirectory,
|
||||
@@ -119,17 +117,6 @@ export function DirectorySettings({
|
||||
onBrowse={() => onBrowseDirectory("gemini")}
|
||||
onReset={() => onResetDirectory("gemini")}
|
||||
/>
|
||||
|
||||
<DirectoryInput
|
||||
label={t("settings.opencodeConfigDir")}
|
||||
description={undefined}
|
||||
value={opencodeDir}
|
||||
resolvedValue={resolvedDirs.opencode}
|
||||
placeholder={t("settings.browsePlaceholderOpencode")}
|
||||
onChange={(val) => onDirectoryChange("opencode", val)}
|
||||
onBrowse={() => onBrowseDirectory("opencode")}
|
||||
onReset={() => onResetDirectory("opencode")}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -34,9 +34,6 @@ import { settingsApi } from "@/lib/api";
|
||||
import { LanguageSettings } from "@/components/settings/LanguageSettings";
|
||||
import { ThemeSettings } from "@/components/settings/ThemeSettings";
|
||||
import { WindowSettings } from "@/components/settings/WindowSettings";
|
||||
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
|
||||
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
|
||||
import { TerminalSettings } from "@/components/settings/TerminalSettings";
|
||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
@@ -243,26 +240,10 @@ export function SettingsPage({
|
||||
onChange={(lang) => handleAutoSave({ language: lang })}
|
||||
/>
|
||||
<ThemeSettings />
|
||||
<AppVisibilitySettings
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
<WindowSettings
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
<SkillSyncMethodSettings
|
||||
value={settings.skillSyncMethod ?? "auto"}
|
||||
onChange={(method) =>
|
||||
handleAutoSave({ skillSyncMethod: method })
|
||||
}
|
||||
/>
|
||||
<TerminalSettings
|
||||
value={settings.preferredTerminal}
|
||||
onChange={(terminal) =>
|
||||
handleAutoSave({ preferredTerminal: terminal })
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
@@ -307,7 +288,6 @@ export function SettingsPage({
|
||||
claudeDir={settings.claudeConfigDir}
|
||||
codexDir={settings.codexConfigDir}
|
||||
geminiDir={settings.geminiConfigDir}
|
||||
opencodeDir={settings.opencodeConfigDir}
|
||||
onDirectoryChange={updateDirectory}
|
||||
onBrowseDirectory={browseDirectory}
|
||||
onResetDirectory={resetDirectory}
|
||||
@@ -477,28 +457,6 @@ export function SettingsPage({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="test"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
@@ -597,6 +555,28 @@ export function SettingsPage({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="rectifier"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.rectifier.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.rectifier.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<RectifierConfigPanel />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="logConfig"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SkillSyncMethod } from "@/types";
|
||||
|
||||
export interface SkillSyncMethodSettingsProps {
|
||||
value: SkillSyncMethod;
|
||||
onChange: (value: SkillSyncMethod) => void;
|
||||
}
|
||||
|
||||
export function SkillSyncMethodSettings({
|
||||
value,
|
||||
onChange,
|
||||
}: SkillSyncMethodSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Handle default values: undefined or "auto" defaults to symlink display
|
||||
const displayValue = value === "copy" ? "copy" : "symlink";
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("settings.skillSync.title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.skillSync.description")}
|
||||
</p>
|
||||
</header>
|
||||
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
||||
<SyncMethodButton
|
||||
active={displayValue === "symlink"}
|
||||
onClick={() => onChange("symlink")}
|
||||
>
|
||||
{t("settings.skillSync.symlink")}
|
||||
</SyncMethodButton>
|
||||
<SyncMethodButton
|
||||
active={displayValue === "copy"}
|
||||
onClick={() => onChange("copy")}
|
||||
>
|
||||
{t("settings.skillSync.copy")}
|
||||
</SyncMethodButton>
|
||||
</div>
|
||||
{displayValue === "symlink" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.skillSync.symlinkHint")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SyncMethodButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SyncMethodButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: SyncMethodButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"min-w-[96px]",
|
||||
active
|
||||
? "shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isMac, isWindows, isLinux } from "@/lib/platform";
|
||||
|
||||
// Terminal options per platform
|
||||
const MACOS_TERMINALS = [
|
||||
{ value: "terminal", labelKey: "settings.terminal.options.macos.terminal" },
|
||||
{ value: "iterm2", labelKey: "settings.terminal.options.macos.iterm2" },
|
||||
{ value: "alacritty", labelKey: "settings.terminal.options.macos.alacritty" },
|
||||
{ value: "kitty", labelKey: "settings.terminal.options.macos.kitty" },
|
||||
{ value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" },
|
||||
] as const;
|
||||
|
||||
const WINDOWS_TERMINALS = [
|
||||
{ value: "cmd", labelKey: "settings.terminal.options.windows.cmd" },
|
||||
{
|
||||
value: "powershell",
|
||||
labelKey: "settings.terminal.options.windows.powershell",
|
||||
},
|
||||
{ value: "wt", labelKey: "settings.terminal.options.windows.wt" },
|
||||
] as const;
|
||||
|
||||
const LINUX_TERMINALS = [
|
||||
{
|
||||
value: "gnome-terminal",
|
||||
labelKey: "settings.terminal.options.linux.gnomeTerminal",
|
||||
},
|
||||
{ value: "konsole", labelKey: "settings.terminal.options.linux.konsole" },
|
||||
{
|
||||
value: "xfce4-terminal",
|
||||
labelKey: "settings.terminal.options.linux.xfce4Terminal",
|
||||
},
|
||||
{ value: "alacritty", labelKey: "settings.terminal.options.linux.alacritty" },
|
||||
{ value: "kitty", labelKey: "settings.terminal.options.linux.kitty" },
|
||||
{ value: "ghostty", labelKey: "settings.terminal.options.linux.ghostty" },
|
||||
] as const;
|
||||
|
||||
// Get terminals for the current platform
|
||||
function getTerminalOptions() {
|
||||
if (isMac()) {
|
||||
return MACOS_TERMINALS;
|
||||
}
|
||||
if (isWindows()) {
|
||||
return WINDOWS_TERMINALS;
|
||||
}
|
||||
if (isLinux()) {
|
||||
return LINUX_TERMINALS;
|
||||
}
|
||||
// Fallback to macOS options
|
||||
return MACOS_TERMINALS;
|
||||
}
|
||||
|
||||
// Get default terminal for the current platform
|
||||
function getDefaultTerminal(): string {
|
||||
if (isMac()) {
|
||||
return "terminal";
|
||||
}
|
||||
if (isWindows()) {
|
||||
return "cmd";
|
||||
}
|
||||
if (isLinux()) {
|
||||
return "gnome-terminal";
|
||||
}
|
||||
return "terminal";
|
||||
}
|
||||
|
||||
export interface TerminalSettingsProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function TerminalSettings({ value, onChange }: TerminalSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const terminals = getTerminalOptions();
|
||||
const defaultTerminal = getDefaultTerminal();
|
||||
|
||||
// Use value or default
|
||||
const currentValue = value || defaultTerminal;
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{t("settings.terminal.title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.description")}
|
||||
</p>
|
||||
</header>
|
||||
<Select value={currentValue} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{terminals.map((terminal) => (
|
||||
<SelectItem key={terminal.value} value={terminal.value}>
|
||||
{t(terminal.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.fallbackHint")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
|
||||
import { ToggleRow } from "@/components/ui/toggle-row";
|
||||
import { AppWindow, MonitorUp, Power } from "lucide-react";
|
||||
|
||||
interface WindowSettingsProps {
|
||||
settings: SettingsFormState;
|
||||
@@ -28,11 +28,13 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<EyeOff className="h-4 w-4 text-green-500" />}
|
||||
title={t("settings.silentStartup")}
|
||||
description={t("settings.silentStartupDescription")}
|
||||
checked={!!settings.silentStartup}
|
||||
onCheckedChange={(value) => onChange({ silentStartup: value })}
|
||||
icon={<AppWindow className="h-4 w-4 text-blue-500" />}
|
||||
title={t("settings.minimizeToTray")}
|
||||
description={t("settings.minimizeToTrayDescription")}
|
||||
checked={settings.minimizeToTrayOnClose}
|
||||
onCheckedChange={(value) =>
|
||||
onChange({ minimizeToTrayOnClose: value })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
@@ -52,17 +54,44 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
||||
checked={!!settings.skipClaudeOnboarding}
|
||||
onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<AppWindow className="h-4 w-4 text-blue-500" />}
|
||||
title={t("settings.minimizeToTray")}
|
||||
description={t("settings.minimizeToTrayDescription")}
|
||||
checked={settings.minimizeToTrayOnClose}
|
||||
onCheckedChange={(value) =>
|
||||
onChange({ minimizeToTrayOnClose: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: ToggleRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{title}</p>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
aria-label={title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,17 +65,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
const addRepoMutation = useAddSkillRepo();
|
||||
const removeRepoMutation = useRemoveSkillRepo();
|
||||
|
||||
// 已安装的 skill key 集合(使用 directory + repoOwner + repoName 组合判断)
|
||||
const installedKeys = useMemo(() => {
|
||||
// 已安装的 directory 集合
|
||||
const installedDirs = useMemo(() => {
|
||||
if (!installedSkills) return new Set<string>();
|
||||
return new Set(
|
||||
installedSkills.map((s) => {
|
||||
// 构建唯一 key:directory + repoOwner + repoName
|
||||
const owner = s.repoOwner?.toLowerCase() || "";
|
||||
const name = s.repoName?.toLowerCase() || "";
|
||||
return `${s.directory.toLowerCase()}:${owner}:${name}`;
|
||||
}),
|
||||
);
|
||||
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
|
||||
}, [installedSkills]);
|
||||
|
||||
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
|
||||
@@ -87,14 +80,12 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
const installName =
|
||||
d.directory.split("/").pop()?.toLowerCase() ||
|
||||
d.directory.toLowerCase();
|
||||
// 使用 directory + repoOwner + repoName 组合判断是否已安装
|
||||
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
|
||||
return {
|
||||
...d,
|
||||
installed: installedKeys.has(key),
|
||||
installed: installedDirs.has(installName),
|
||||
};
|
||||
});
|
||||
}, [discoverableSkills, installedKeys]);
|
||||
}, [discoverableSkills, installedDirs]);
|
||||
|
||||
const loading = loadingDiscoverable || fetchingDiscoverable;
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export interface ToggleRowProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
}: ToggleRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{title}</p>
|
||||
{description ? (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-label={title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,31 +19,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
|
||||
import { PricingEditModal } from "./PricingEditModal";
|
||||
import type { ModelPricing } from "@/types/usage";
|
||||
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
|
||||
const PRICING_APPS = ["claude", "codex", "gemini"] as const;
|
||||
type PricingApp = (typeof PRICING_APPS)[number];
|
||||
type PricingModelSource = "request" | "response";
|
||||
|
||||
interface AppConfig {
|
||||
multiplier: string;
|
||||
source: PricingModelSource;
|
||||
}
|
||||
|
||||
type AppConfigState = Record<PricingApp, AppConfig>;
|
||||
import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
export function PricingConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -51,137 +31,13 @@ export function PricingConfigPanel() {
|
||||
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
// 三个应用的配置状态
|
||||
const [appConfigs, setAppConfigs] = useState<AppConfigState>({
|
||||
claude: { multiplier: "1", source: "response" },
|
||||
codex: { multiplier: "1", source: "response" },
|
||||
gemini: { multiplier: "1", source: "response" },
|
||||
});
|
||||
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
|
||||
null,
|
||||
);
|
||||
const [isConfigLoading, setIsConfigLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 检查是否有改动
|
||||
const isDirty =
|
||||
originalConfigs !== null &&
|
||||
PRICING_APPS.some(
|
||||
(app) =>
|
||||
appConfigs[app].multiplier !== originalConfigs[app].multiplier ||
|
||||
appConfigs[app].source !== originalConfigs[app].source,
|
||||
);
|
||||
|
||||
// 加载所有应用的配置
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadAllConfigs = async () => {
|
||||
setIsConfigLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
PRICING_APPS.map(async (app) => {
|
||||
const [multiplier, source] = await Promise.all([
|
||||
proxyApi.getDefaultCostMultiplier(app),
|
||||
proxyApi.getPricingModelSource(app),
|
||||
]);
|
||||
return {
|
||||
app,
|
||||
multiplier,
|
||||
source: (source === "request"
|
||||
? "request"
|
||||
: "response") as PricingModelSource,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
const newState: AppConfigState = {
|
||||
claude: { multiplier: "1", source: "response" },
|
||||
codex: { multiplier: "1", source: "response" },
|
||||
gemini: { multiplier: "1", source: "response" },
|
||||
};
|
||||
for (const result of results) {
|
||||
newState[result.app] = {
|
||||
multiplier: result.multiplier,
|
||||
source: result.source,
|
||||
};
|
||||
}
|
||||
setAppConfigs(newState);
|
||||
setOriginalConfigs(newState);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "Unknown error";
|
||||
toast.error(
|
||||
t("settings.globalProxy.pricingLoadFailed", { error: message }),
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) setIsConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllConfigs();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// 保存所有配置
|
||||
const handleSaveAll = async () => {
|
||||
// 验证所有倍率
|
||||
for (const app of PRICING_APPS) {
|
||||
const trimmed = appConfigs[app].multiplier.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(
|
||||
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierRequired")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
toast.error(
|
||||
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
PRICING_APPS.flatMap((app) => [
|
||||
proxyApi.setDefaultCostMultiplier(
|
||||
app,
|
||||
appConfigs[app].multiplier.trim(),
|
||||
),
|
||||
proxyApi.setPricingModelSource(app, appConfigs[app].source),
|
||||
]),
|
||||
);
|
||||
toast.success(t("settings.globalProxy.pricingSaved"));
|
||||
setOriginalConfigs({ ...appConfigs });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "Unknown error";
|
||||
toast.error(
|
||||
t("settings.globalProxy.pricingSaveFailed", { error: message }),
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const handleDelete = (modelId: string) => {
|
||||
deleteMutation.mutate(modelId, {
|
||||
onSuccess: () => setDeleteConfirm(null),
|
||||
onSuccess: () => {
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -199,240 +55,149 @@ export function PricingConfigPanel() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<Card className="border rounded-lg">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Card className="border rounded-lg">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 全局计费默认配置 - 紧凑表格布局 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("settings.globalProxy.pricingDefaultsTitle")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.globalProxy.pricingDefaultsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={isConfigLoading || isSaving || !isDirty}
|
||||
size="sm"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{t("common.saving")}
|
||||
</>
|
||||
) : (
|
||||
t("common.save")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isConfigLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/30">
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
{t("settings.globalProxy.pricingAppLabel")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
{t("settings.globalProxy.defaultCostMultiplierLabel")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
{t("settings.globalProxy.pricingModelSourceLabel")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PRICING_APPS.map((app, idx) => (
|
||||
<tr
|
||||
key={app}
|
||||
className={
|
||||
idx < PRICING_APPS.length - 1
|
||||
? "border-b border-border/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-1.5 font-medium">
|
||||
{t(`apps.${app}`)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={appConfigs[app].multiplier}
|
||||
onChange={(e) =>
|
||||
setAppConfigs((prev) => ({
|
||||
...prev,
|
||||
[app]: { ...prev[app], multiplier: e.target.value },
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
placeholder="1"
|
||||
className="h-7 w-24"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Select
|
||||
value={appConfigs[app].source}
|
||||
onValueChange={(value) =>
|
||||
setAppConfigs((prev) => ({
|
||||
...prev,
|
||||
[app]: {
|
||||
...prev[app],
|
||||
source: value as PricingModelSource,
|
||||
},
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="response">
|
||||
{t(
|
||||
"settings.globalProxy.pricingModelSourceResponse",
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="request">
|
||||
{t(
|
||||
"settings.globalProxy.pricingModelSourceRequest",
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
</h4>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* 模型定价配置 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
</h4>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model")}</TableHead>
|
||||
<TableHead>{t("usage.displayName")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model")}</TableHead>
|
||||
<TableHead>{t("usage.displayName")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{model.modelId}
|
||||
</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.inputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.outputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheReadCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheCreationCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{model.modelId}
|
||||
</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.inputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.outputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheReadCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheCreationCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingModel && (
|
||||
|
||||
@@ -184,9 +184,6 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.inputCost", "输入成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.inputCostUsd).toFixed(6)}
|
||||
@@ -195,9 +192,6 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.outputCost", "输出成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.outputCostUsd).toFixed(6)}
|
||||
@@ -206,9 +200,6 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheReadCost", "缓存读取成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.cacheReadCostUsd).toFixed(6)}
|
||||
@@ -217,35 +208,14 @@ export function RequestDetailPanel({
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.cacheCreationCost", "缓存写入成本")}
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.baseCost", "基础")})
|
||||
</span>
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
${parseFloat(request.cacheCreationCostUsd).toFixed(6)}
|
||||
</dd>
|
||||
</div>
|
||||
{/* 显示成本倍率(如果不等于1) */}
|
||||
{request.costMultiplier &&
|
||||
parseFloat(request.costMultiplier) !== 1 && (
|
||||
<div className="col-span-2 border-t pt-3">
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.costMultiplier", "成本倍率")}
|
||||
</dt>
|
||||
<dd className="font-mono">×{request.costMultiplier}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`col-span-2 ${request.costMultiplier && parseFloat(request.costMultiplier) !== 1 ? "" : "border-t"} pt-3`}
|
||||
>
|
||||
<div className="col-span-2 border-t pt-3">
|
||||
<dt className="text-muted-foreground">
|
||||
{t("usage.totalCost", "总成本")}
|
||||
{request.costMultiplier &&
|
||||
parseFloat(request.costMultiplier) !== 1 && (
|
||||
<span className="ml-1 text-xs">
|
||||
({t("usage.withMultiplier", "含倍率")})
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-primary">
|
||||
${parseFloat(request.totalCostUsd).toFixed(6)}
|
||||
|
||||
@@ -250,7 +250,7 @@ export function RequestLogTable() {
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.provider")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">
|
||||
<TableHead className="min-w-[280px] whitespace-nowrap">
|
||||
{t("usage.billingModel")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
@@ -265,9 +265,6 @@ export function RequestLogTable() {
|
||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||
{t("usage.cacheCreationTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.multiplier")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.totalCost")}
|
||||
</TableHead>
|
||||
@@ -283,7 +280,7 @@ export function RequestLogTable() {
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={11}
|
||||
colSpan={10}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData")}
|
||||
@@ -300,25 +297,11 @@ export function RequestLogTable() {
|
||||
<TableCell>
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.model}
|
||||
</div>
|
||||
{log.requestModel && log.requestModel !== log.model && (
|
||||
<div
|
||||
className="truncate text-muted-foreground text-[10px]"
|
||||
title={log.requestModel}
|
||||
>
|
||||
← {log.requestModel}
|
||||
</div>
|
||||
)}
|
||||
<TableCell
|
||||
className="font-mono text-sm max-w-[280px] truncate"
|
||||
title={log.model}
|
||||
>
|
||||
{log.model}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.inputTokens.toLocaleString()}
|
||||
@@ -332,15 +315,6 @@ export function RequestLogTable() {
|
||||
<TableCell className="text-right">
|
||||
{log.cacheCreationTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{parseFloat(log.costMultiplier) !== 1 ? (
|
||||
<span className="text-orange-600">
|
||||
×{log.costMultiplier}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">×1</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||
</TableCell>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const providerPresets: ProviderPreset[] = [
|
||||
iconColor: "#0F62FE",
|
||||
},
|
||||
{
|
||||
name: "Zhipu GLM en",
|
||||
name: "Z.ai GLM",
|
||||
websiteUrl: "https://z.ai",
|
||||
apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB",
|
||||
settingsConfig: {
|
||||
@@ -211,7 +211,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
icon: "catcoder",
|
||||
},
|
||||
{
|
||||
name: "Longcat",
|
||||
@@ -337,42 +336,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "aihubmix",
|
||||
iconColor: "#006FFB",
|
||||
},
|
||||
{
|
||||
name: "SiliconFlow",
|
||||
websiteUrl: "https://siliconflow.cn",
|
||||
apiKeyUrl: "https://cloud.siliconflow.cn/me/account/ak",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.cn",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "Pro/MiniMaxAI/MiniMax-M2.1",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "siliconflow",
|
||||
iconColor: "#6E29F6",
|
||||
},
|
||||
{
|
||||
name: "SiliconFlow en",
|
||||
websiteUrl: "https://siliconflow.com",
|
||||
apiKeyUrl: "https://cloud.siliconflow.com/account/ak",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.siliconflow.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "MiniMaxAI/MiniMax-M2.1",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
icon: "siliconflow",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "DMXAPI",
|
||||
websiteUrl: "https://www.dmxapi.cn",
|
||||
@@ -449,22 +412,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
{
|
||||
name: "RightCode",
|
||||
websiteUrl: "https://www.right.codes",
|
||||
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://www.right.codes/claude",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "rightcode",
|
||||
icon: "rc",
|
||||
iconColor: "#E96B2C",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
|
||||
@@ -192,22 +192,6 @@ requires_openai_auth = true`,
|
||||
icon: "aigocode",
|
||||
iconColor: "#5B7FFF",
|
||||
},
|
||||
{
|
||||
name: "RightCode",
|
||||
websiteUrl: "https://www.right.codes",
|
||||
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"rightcode",
|
||||
"https://right.codes/codex/v1",
|
||||
"gpt-5.2",
|
||||
),
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "rightcode",
|
||||
icon: "rc",
|
||||
iconColor: "#E96B2C",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
|
||||
@@ -103,12 +103,12 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Zhipu GLM en",
|
||||
name: "Z.ai GLM",
|
||||
websiteUrl: "https://z.ai",
|
||||
apiKeyUrl: "https://z.ai/subscribe?ic=8JVLJQFSKB",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Zhipu GLM en",
|
||||
name: "Z.ai GLM",
|
||||
options: {
|
||||
baseURL: "https://api.z.ai/v1",
|
||||
apiKey: "",
|
||||
@@ -302,7 +302,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
icon: "catcoder",
|
||||
},
|
||||
{
|
||||
name: "Longcat",
|
||||
@@ -646,35 +645,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RightCode",
|
||||
websiteUrl: "https://www.right.codes",
|
||||
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai",
|
||||
name: "RightCode",
|
||||
options: {
|
||||
baseURL: "https://right.codes/codex/v1",
|
||||
apiKey: "",
|
||||
},
|
||||
models: {
|
||||
"gpt-5.2": { name: "GPT-5.2" },
|
||||
"gpt-5.2-codex": { name: "GPT-5.2 Codex" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "rightcode",
|
||||
icon: "rc",
|
||||
iconColor: "#E96B2C",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ========== 自定义模板 ==========
|
||||
{
|
||||
|
||||
@@ -5,14 +5,13 @@ import { homeDir, join } from "@tauri-apps/api/path";
|
||||
import { settingsApi, type AppId } from "@/lib/api";
|
||||
import type { SettingsFormState } from "./useSettingsForm";
|
||||
|
||||
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode";
|
||||
type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini";
|
||||
|
||||
export interface ResolvedDirectories {
|
||||
appConfig: string;
|
||||
claude: string;
|
||||
codex: string;
|
||||
gemini: string;
|
||||
opencode: string;
|
||||
}
|
||||
|
||||
const sanitizeDir = (value?: string | null): string | undefined => {
|
||||
@@ -40,13 +39,7 @@ const computeDefaultConfigDir = async (
|
||||
try {
|
||||
const home = await homeDir();
|
||||
const folder =
|
||||
app === "claude"
|
||||
? ".claude"
|
||||
: app === "codex"
|
||||
? ".codex"
|
||||
: app === "gemini"
|
||||
? ".gemini"
|
||||
: ".config/opencode";
|
||||
app === "claude" ? ".claude" : app === "codex" ? ".codex" : ".gemini";
|
||||
return await join(home, folder);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -77,7 +70,6 @@ export interface UseDirectorySettingsResult {
|
||||
claudeDir?: string,
|
||||
codexDir?: string,
|
||||
geminiDir?: string,
|
||||
opencodeDir?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -104,7 +96,6 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
opencode: "",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -113,7 +104,6 @@ export function useDirectorySettings({
|
||||
claude: "",
|
||||
codex: "",
|
||||
gemini: "",
|
||||
opencode: "",
|
||||
});
|
||||
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
|
||||
|
||||
@@ -129,23 +119,19 @@ export function useDirectorySettings({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
geminiDir,
|
||||
opencodeDir,
|
||||
defaultAppConfig,
|
||||
defaultClaudeDir,
|
||||
defaultCodexDir,
|
||||
defaultGeminiDir,
|
||||
defaultOpencodeDir,
|
||||
] = await Promise.all([
|
||||
settingsApi.getAppConfigDirOverride(),
|
||||
settingsApi.getConfigDir("claude"),
|
||||
settingsApi.getConfigDir("codex"),
|
||||
settingsApi.getConfigDir("gemini"),
|
||||
settingsApi.getConfigDir("opencode"),
|
||||
computeDefaultAppConfigDir(),
|
||||
computeDefaultConfigDir("claude"),
|
||||
computeDefaultConfigDir("codex"),
|
||||
computeDefaultConfigDir("gemini"),
|
||||
computeDefaultConfigDir("opencode"),
|
||||
]);
|
||||
|
||||
if (!active) return;
|
||||
@@ -157,7 +143,6 @@ export function useDirectorySettings({
|
||||
claude: defaultClaudeDir ?? "",
|
||||
codex: defaultCodexDir ?? "",
|
||||
gemini: defaultGeminiDir ?? "",
|
||||
opencode: defaultOpencodeDir ?? "",
|
||||
};
|
||||
|
||||
setAppConfigDir(normalizedOverride);
|
||||
@@ -168,7 +153,6 @@ export function useDirectorySettings({
|
||||
claude: claudeDir || defaultsRef.current.claude,
|
||||
codex: codexDir || defaultsRef.current.codex,
|
||||
gemini: geminiDir || defaultsRef.current.gemini,
|
||||
opencode: opencodeDir || defaultsRef.current.opencode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -199,9 +183,7 @@ export function useDirectorySettings({
|
||||
? { claudeConfigDir: sanitized }
|
||||
: key === "codex"
|
||||
? { codexConfigDir: sanitized }
|
||||
: key === "gemini"
|
||||
? { geminiConfigDir: sanitized }
|
||||
: { opencodeConfigDir: sanitized },
|
||||
: { geminiConfigDir: sanitized },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -223,13 +205,7 @@ export function useDirectorySettings({
|
||||
const updateDirectory = useCallback(
|
||||
(app: AppId, value?: string) => {
|
||||
updateDirectoryState(
|
||||
app === "claude"
|
||||
? "claude"
|
||||
: app === "codex"
|
||||
? "codex"
|
||||
: app === "gemini"
|
||||
? "gemini"
|
||||
: "opencode",
|
||||
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini",
|
||||
value,
|
||||
);
|
||||
},
|
||||
@@ -239,21 +215,13 @@ export function useDirectorySettings({
|
||||
const browseDirectory = useCallback(
|
||||
async (app: AppId) => {
|
||||
const key: DirectoryKey =
|
||||
app === "claude"
|
||||
? "claude"
|
||||
: app === "codex"
|
||||
? "codex"
|
||||
: app === "gemini"
|
||||
? "gemini"
|
||||
: "opencode";
|
||||
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
|
||||
const currentValue =
|
||||
key === "claude"
|
||||
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
|
||||
: key === "codex"
|
||||
? (settings?.codexConfigDir ?? resolvedDirs.codex)
|
||||
: key === "gemini"
|
||||
? (settings?.geminiConfigDir ?? resolvedDirs.gemini)
|
||||
: (settings?.opencodeConfigDir ?? resolvedDirs.opencode);
|
||||
: (settings?.geminiConfigDir ?? resolvedDirs.gemini);
|
||||
|
||||
try {
|
||||
const picked = await settingsApi.selectConfigDirectory(currentValue);
|
||||
@@ -295,13 +263,7 @@ export function useDirectorySettings({
|
||||
const resetDirectory = useCallback(
|
||||
async (app: AppId) => {
|
||||
const key: DirectoryKey =
|
||||
app === "claude"
|
||||
? "claude"
|
||||
: app === "codex"
|
||||
? "codex"
|
||||
: app === "gemini"
|
||||
? "gemini"
|
||||
: "opencode";
|
||||
app === "claude" ? "claude" : app === "codex" ? "codex" : "gemini";
|
||||
if (!defaultsRef.current[key]) {
|
||||
const fallback = await computeDefaultConfigDir(app);
|
||||
if (fallback) {
|
||||
@@ -330,12 +292,7 @@ export function useDirectorySettings({
|
||||
}, [updateDirectoryState]);
|
||||
|
||||
const resetAllDirectories = useCallback(
|
||||
(
|
||||
claudeDir?: string,
|
||||
codexDir?: string,
|
||||
geminiDir?: string,
|
||||
opencodeDir?: string,
|
||||
) => {
|
||||
(claudeDir?: string, codexDir?: string, geminiDir?: string) => {
|
||||
setAppConfigDir(initialAppConfigDirRef.current);
|
||||
setResolvedDirs({
|
||||
appConfig:
|
||||
@@ -343,7 +300,6 @@ export function useDirectorySettings({
|
||||
claude: claudeDir ?? defaultsRef.current.claude,
|
||||
codex: codexDir ?? defaultsRef.current.codex,
|
||||
gemini: geminiDir ?? defaultsRef.current.gemini,
|
||||
opencode: opencodeDir ?? defaultsRef.current.opencode,
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -109,7 +109,6 @@ export function useSettings(): UseSettingsResult {
|
||||
sanitizeDir(data?.claudeConfigDir),
|
||||
sanitizeDir(data?.codexConfigDir),
|
||||
sanitizeDir(data?.geminiConfigDir),
|
||||
sanitizeDir(data?.opencodeConfigDir),
|
||||
);
|
||||
setRequiresRestart(false);
|
||||
}, [
|
||||
@@ -132,16 +131,12 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
language: mergedSettings.language,
|
||||
};
|
||||
|
||||
@@ -243,21 +238,16 @@ export function useSettings(): UseSettingsResult {
|
||||
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
|
||||
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
|
||||
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
|
||||
const sanitizedOpencodeDir = sanitizeDir(
|
||||
mergedSettings.opencodeConfigDir,
|
||||
);
|
||||
const previousAppDir = initialAppConfigDir;
|
||||
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
|
||||
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
|
||||
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
|
||||
const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir);
|
||||
|
||||
const payload: Settings = {
|
||||
...mergedSettings,
|
||||
claudeConfigDir: sanitizedClaudeDir,
|
||||
codexConfigDir: sanitizedCodexDir,
|
||||
geminiConfigDir: sanitizedGeminiDir,
|
||||
opencodeConfigDir: sanitizedOpencodeDir,
|
||||
language: mergedSettings.language,
|
||||
};
|
||||
|
||||
@@ -354,17 +344,11 @@ export function useSettings(): UseSettingsResult {
|
||||
console.warn("[useSettings] Failed to refresh tray menu", error);
|
||||
}
|
||||
|
||||
// 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置
|
||||
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
|
||||
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
|
||||
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
|
||||
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
|
||||
const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir;
|
||||
if (
|
||||
claudeDirChanged ||
|
||||
codexDirChanged ||
|
||||
geminiDirChanged ||
|
||||
opencodeDirChanged
|
||||
) {
|
||||
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
|
||||
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||
if (!syncResult.ok) {
|
||||
console.warn(
|
||||
|
||||
@@ -83,12 +83,9 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
|
||||
enableClaudePluginIntegration:
|
||||
data.enableClaudePluginIntegration ?? false,
|
||||
silentStartup: data.silentStartup ?? false,
|
||||
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
|
||||
skipClaudeOnboarding: data.skipClaudeOnboarding ?? true,
|
||||
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
|
||||
language: normalizedLanguage,
|
||||
};
|
||||
|
||||
@@ -106,7 +103,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
enableClaudePluginIntegration: false,
|
||||
skipClaudeOnboarding: false,
|
||||
skipClaudeOnboarding: true,
|
||||
language: readPersistedLanguage(),
|
||||
} as SettingsFormState);
|
||||
|
||||
@@ -141,12 +138,9 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
|
||||
enableClaudePluginIntegration:
|
||||
serverData.enableClaudePluginIntegration ?? false,
|
||||
silentStartup: serverData.silentStartup ?? false,
|
||||
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
|
||||
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? true,
|
||||
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
||||
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
|
||||
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
|
||||
language: normalizedLanguage,
|
||||
};
|
||||
|
||||
|
||||
+10
-103
@@ -265,8 +265,6 @@
|
||||
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
|
||||
"launchOnStartup": "Launch on Startup",
|
||||
"launchOnStartupDescription": "Automatically run CC Switch when system starts",
|
||||
"silentStartup": "Silent Startup",
|
||||
"silentStartupDescription": "Start in background mode without showing main window",
|
||||
"autoLaunchFailed": "Failed to set auto-launch",
|
||||
"minimizeToTray": "Minimize to tray on close",
|
||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||
@@ -274,48 +272,6 @@
|
||||
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
|
||||
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
|
||||
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
|
||||
"appVisibility": {
|
||||
"title": "Homepage Display",
|
||||
"description": "Choose which apps to show on the homepage",
|
||||
"claudeDesc": "Anthropic Claude Code CLI",
|
||||
"codexDesc": "OpenAI Codex CLI",
|
||||
"geminiDesc": "Google Gemini CLI",
|
||||
"opencodeDesc": "OpenCode CLI"
|
||||
},
|
||||
"skillSync": {
|
||||
"title": "Skill Sync Method",
|
||||
"description": "Choose how to sync Skills files",
|
||||
"symlink": "Symlink",
|
||||
"copy": "Copy Files",
|
||||
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Preferred Terminal",
|
||||
"description": "Choose which terminal app to use when clicking the terminal button",
|
||||
"fallbackHint": "If the selected terminal is unavailable, the system default will be used",
|
||||
"options": {
|
||||
"macos": {
|
||||
"terminal": "Terminal.app",
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "Command Prompt",
|
||||
"powershell": "PowerShell",
|
||||
"wt": "Windows Terminal"
|
||||
},
|
||||
"linux": {
|
||||
"gnomeTerminal": "GNOME Terminal",
|
||||
"konsole": "Konsole",
|
||||
"xfce4Terminal": "Xfce4 Terminal",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
||||
"appConfigDir": "CC Switch Configuration Directory",
|
||||
@@ -327,12 +283,9 @@
|
||||
"codexConfigDirDescription": "Override Codex configuration directory.",
|
||||
"geminiConfigDir": "Gemini Configuration Directory",
|
||||
"geminiConfigDirDescription": "Override Gemini configuration directory (.env).",
|
||||
"opencodeConfigDir": "OpenCode Configuration Directory",
|
||||
"opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).",
|
||||
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "e.g., /home/<your-username>/.gemini",
|
||||
"browsePlaceholderOpencode": "e.g., /home/<your-username>/.config/opencode",
|
||||
"browseDirectory": "Browse Directory",
|
||||
"resetDefault": "Reset to default directory (takes effect after saving)",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
@@ -376,25 +329,11 @@
|
||||
"saved": "Proxy settings saved",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"testSuccess": "Connected! Latency {{latency}}ms",
|
||||
"testFailed": "Connection failed: {{error}}",
|
||||
"pricingDefaultsTitle": "Pricing Defaults",
|
||||
"pricingDefaultsDescription": "Set the default multiplier and pricing model source per app.",
|
||||
"pricingAppLabel": "App",
|
||||
"defaultCostMultiplierLabel": "Default Multiplier",
|
||||
"defaultCostMultiplierHint": "Multiplier for cost calculation, decimals supported.",
|
||||
"pricingModelSourceLabel": "Pricing Model Source",
|
||||
"pricingModelSourceRequest": "Request model",
|
||||
"pricingModelSourceResponse": "Response model",
|
||||
"pricingSave": "Save Pricing Defaults",
|
||||
"pricingSaved": "Pricing defaults saved",
|
||||
"pricingSaveFailed": "Failed to save pricing defaults: {{error}}",
|
||||
"pricingLoadFailed": "Failed to load pricing defaults: {{error}}",
|
||||
"defaultCostMultiplierRequired": "Default multiplier is required",
|
||||
"defaultCostMultiplierInvalid": "Invalid multiplier format"
|
||||
"testFailed": "Connection failed: {{error}}"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
@@ -446,8 +385,7 @@
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||
"dmxapi": "Claude Code exclusive model 66% OFF now!",
|
||||
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
|
||||
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!",
|
||||
"rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!"
|
||||
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!"
|
||||
},
|
||||
"parameterConfig": "Parameter Config - {{name}} *",
|
||||
"mainModel": "Main Model (optional)",
|
||||
@@ -526,18 +464,7 @@
|
||||
"useCustomProxy": "Use separate proxy",
|
||||
"proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.",
|
||||
"proxyUsername": "Username (optional)",
|
||||
"proxyPassword": "Password (optional)",
|
||||
"pricingConfig": "Pricing Config",
|
||||
"useCustomPricing": "Use separate config",
|
||||
"pricingConfigDesc": "Configure separate pricing parameters for this provider. Uses global defaults when disabled.",
|
||||
"costMultiplier": "Cost Multiplier",
|
||||
"costMultiplierPlaceholder": "Leave empty to use global default (1)",
|
||||
"costMultiplierHint": "Actual cost = Base cost × Multiplier, supports decimals like 1.5",
|
||||
"pricingModelSourceLabel": "Pricing Mode",
|
||||
"pricingModelSourceInherit": "Inherit global default",
|
||||
"pricingModelSourceRequest": "Request model",
|
||||
"pricingModelSourceResponse": "Response model",
|
||||
"pricingModelSourceHint": "Choose whether to match pricing by request model or response model"
|
||||
"proxyPassword": "Password (optional)"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -640,9 +567,6 @@
|
||||
"cacheCreationTokens": "Cache Creation",
|
||||
"timingInfo": "Duration/TTFT",
|
||||
"status": "Status",
|
||||
"multiplier": "Multiplier",
|
||||
"requestModel": "Request Model",
|
||||
"responseModel": "Response Model",
|
||||
"noData": "No data",
|
||||
"unknownProvider": "Unknown Provider",
|
||||
"stream": "Stream",
|
||||
@@ -685,19 +609,7 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Creation",
|
||||
"cacheRead": "Hit",
|
||||
"baseCost": "Base",
|
||||
"costMultiplier": "Cost Multiplier",
|
||||
"withMultiplier": "with multiplier",
|
||||
"requestDetail": "Request Detail",
|
||||
"requestNotFound": "Request not found",
|
||||
"basicInfo": "Basic Info",
|
||||
"tokenUsage": "Token Usage",
|
||||
"cacheCreationCost": "Cache Creation Cost",
|
||||
"costBreakdown": "Cost Breakdown",
|
||||
"performance": "Performance",
|
||||
"latency": "Latency",
|
||||
"errorMessage": "Error Message"
|
||||
"cacheRead": "Hit"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "Configure Usage Query",
|
||||
@@ -1044,7 +956,6 @@
|
||||
"downloadTimeoutHint": "Please check network connection or retry later",
|
||||
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
|
||||
"skillDirNotFound": "Skill directory not found: {{path}}",
|
||||
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
|
||||
"emptyArchive": "Downloaded archive is empty",
|
||||
"downloadFailed": "Download failed: HTTP {{status}}",
|
||||
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
||||
@@ -1063,8 +974,7 @@
|
||||
"retryLater": "Please retry later",
|
||||
"checkRepoUrl": "Please check repository URL and branch name",
|
||||
"checkDiskSpace": "Please check disk space",
|
||||
"checkPermission": "Please check directory permissions",
|
||||
"uninstallFirst": "Please uninstall the existing skill with the same name first"
|
||||
"checkPermission": "Please check directory permissions"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
@@ -1227,8 +1137,8 @@
|
||||
"tooltip": "Failover priority {{priority}}"
|
||||
},
|
||||
"tooltip": {
|
||||
"enabled": "{{app}} failover enabled\nRequests follow queue priority (P1→P2→...)",
|
||||
"disabled": "Enable {{app}} failover\nSwitches to queue P1 immediately, then falls back on failures"
|
||||
"enabled": "{{app}} failover enabled\nAutomatically switch to next available provider",
|
||||
"disabled": "Enable {{app}} failover\nAutomatically switch when current provider fails"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@@ -1330,18 +1240,15 @@
|
||||
},
|
||||
"switchFailed": "Switch failed: {{error}}",
|
||||
"failover": {
|
||||
"proxyRequired": "Proxy service must be started to configure failover",
|
||||
"autoSwitch": "Auto Failover",
|
||||
"autoSwitchDescription": "When enabled, switches to queue P1 immediately and automatically tries the next provider in the queue on failures"
|
||||
"proxyRequired": "Proxy service must be started to configure failover"
|
||||
},
|
||||
"failoverQueue": {
|
||||
"title": "Failover Queue",
|
||||
"description": "Manage failover order for each app's providers",
|
||||
"info": "When auto failover is enabled, requests follow the queue priority order (P1 first). On failures, the system will try the next provider in the queue.",
|
||||
"info": "The current active provider always takes priority. When requests fail, the system will try other providers in queue order.",
|
||||
"selectProvider": "Select a provider to add to queue",
|
||||
"noAvailableProviders": "No providers available to add",
|
||||
"empty": "Failover queue is empty. Add providers to enable automatic failover.",
|
||||
"orderHint": "Queue order matches the provider list order on the Home page. Reorder providers on the Home page to change priority.",
|
||||
"dragHint": "Drag providers to adjust failover order. Lower numbers have higher priority.",
|
||||
"toggleEnabled": "Enable/Disable",
|
||||
"addSuccess": "Added to failover queue",
|
||||
|
||||
+10
-103
@@ -265,8 +265,6 @@
|
||||
"windowBehaviorHint": "最小化動作や Claude プラグイン連携を設定します。",
|
||||
"launchOnStartup": "起動時に自動実行",
|
||||
"launchOnStartupDescription": "システム起動時に CC Switch を自動起動します",
|
||||
"silentStartup": "サイレント起動",
|
||||
"silentStartupDescription": "起動時にメインウィンドウを表示せず、トレイのみで起動",
|
||||
"autoLaunchFailed": "自動起動の設定に失敗しました",
|
||||
"minimizeToTray": "閉じるときトレイへ最小化",
|
||||
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
|
||||
@@ -274,48 +272,6 @@
|
||||
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
|
||||
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
|
||||
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
|
||||
"appVisibility": {
|
||||
"title": "ホームページ表示",
|
||||
"description": "ホームページに表示するアプリを選択",
|
||||
"claudeDesc": "Anthropic Claude Code CLI",
|
||||
"codexDesc": "OpenAI Codex CLI",
|
||||
"geminiDesc": "Google Gemini CLI",
|
||||
"opencodeDesc": "OpenCode CLI"
|
||||
},
|
||||
"skillSync": {
|
||||
"title": "スキル同期方式",
|
||||
"description": "スキルファイルの同期方法を選択",
|
||||
"symlink": "シンボリックリンク",
|
||||
"copy": "ファイルコピー",
|
||||
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "優先ターミナル",
|
||||
"description": "ターミナルボタンをクリックした時に使用するターミナルアプリを選択",
|
||||
"fallbackHint": "選択したターミナルが利用できない場合、システムのデフォルトが使用されます",
|
||||
"options": {
|
||||
"macos": {
|
||||
"terminal": "Terminal.app",
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "コマンドプロンプト",
|
||||
"powershell": "PowerShell",
|
||||
"wt": "Windows Terminal"
|
||||
},
|
||||
"linux": {
|
||||
"gnomeTerminal": "GNOME Terminal",
|
||||
"konsole": "Konsole",
|
||||
"xfce4Terminal": "Xfce4 Terminal",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
|
||||
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
|
||||
"appConfigDir": "CC Switch 設定ディレクトリ",
|
||||
@@ -327,12 +283,9 @@
|
||||
"codexConfigDirDescription": "Codex の設定ディレクトリを上書きします。",
|
||||
"geminiConfigDir": "Gemini 設定ディレクトリ",
|
||||
"geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。",
|
||||
"opencodeConfigDir": "OpenCode 設定ディレクトリ",
|
||||
"opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。",
|
||||
"browsePlaceholderClaude": "例: /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "例: /home/<your-username>/.codex",
|
||||
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
|
||||
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
|
||||
"browseDirectory": "ディレクトリを選択",
|
||||
"resetDefault": "デフォルトに戻す(保存後に反映)",
|
||||
"checkForUpdates": "アップデートを確認",
|
||||
@@ -376,25 +329,11 @@
|
||||
"saved": "プロキシ設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"testSuccess": "接続成功!遅延 {{latency}}ms",
|
||||
"testFailed": "接続に失敗しました: {{error}}",
|
||||
"pricingDefaultsTitle": "課金のデフォルト設定",
|
||||
"pricingDefaultsDescription": "アプリごとのデフォルト倍率と課金モードを設定します。",
|
||||
"pricingAppLabel": "アプリ",
|
||||
"defaultCostMultiplierLabel": "デフォルト倍率",
|
||||
"defaultCostMultiplierHint": "コスト計算用の倍率(小数対応)。",
|
||||
"pricingModelSourceLabel": "課金モード",
|
||||
"pricingModelSourceRequest": "リクエストモデル",
|
||||
"pricingModelSourceResponse": "レスポンスモデル",
|
||||
"pricingSave": "課金設定を保存",
|
||||
"pricingSaved": "課金設定を保存しました",
|
||||
"pricingSaveFailed": "課金設定の保存に失敗しました: {{error}}",
|
||||
"pricingLoadFailed": "課金設定の読み込みに失敗しました: {{error}}",
|
||||
"defaultCostMultiplierRequired": "デフォルト倍率は必須です",
|
||||
"defaultCostMultiplierInvalid": "デフォルト倍率の形式が正しくありません"
|
||||
"testFailed": "接続に失敗しました: {{error}}"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
@@ -446,8 +385,7 @@
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
|
||||
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
||||
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
|
||||
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!"
|
||||
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!"
|
||||
},
|
||||
"parameterConfig": "パラメーター設定 - {{name}} *",
|
||||
"mainModel": "メインモデル(任意)",
|
||||
@@ -526,18 +464,7 @@
|
||||
"useCustomProxy": "個別プロキシを使用",
|
||||
"proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。",
|
||||
"proxyUsername": "ユーザー名(任意)",
|
||||
"proxyPassword": "パスワード(任意)",
|
||||
"pricingConfig": "課金設定",
|
||||
"useCustomPricing": "個別設定を使用",
|
||||
"pricingConfigDesc": "このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。",
|
||||
"costMultiplier": "コスト倍率",
|
||||
"costMultiplierPlaceholder": "空白の場合はグローバル設定を使用(1)",
|
||||
"costMultiplierHint": "実際のコスト = 基本コスト × 倍率、1.5 などの小数をサポート",
|
||||
"pricingModelSourceLabel": "課金モード",
|
||||
"pricingModelSourceInherit": "グローバル設定を継承",
|
||||
"pricingModelSourceRequest": "リクエストモデル",
|
||||
"pricingModelSourceResponse": "レスポンスモデル",
|
||||
"pricingModelSourceHint": "リクエストモデルまたはレスポンスモデルで価格を照合するかを選択"
|
||||
"proxyPassword": "パスワード(任意)"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -640,9 +567,6 @@
|
||||
"cacheCreationTokens": "キャッシュ作成",
|
||||
"timingInfo": "応答時間/TTFT",
|
||||
"status": "ステータス",
|
||||
"multiplier": "倍率",
|
||||
"requestModel": "リクエストモデル",
|
||||
"responseModel": "レスポンスモデル",
|
||||
"noData": "データなし",
|
||||
"unknownProvider": "不明なプロバイダー",
|
||||
"stream": "ストリーム",
|
||||
@@ -685,19 +609,7 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "作成",
|
||||
"cacheRead": "ヒット",
|
||||
"baseCost": "基本",
|
||||
"costMultiplier": "コスト倍率",
|
||||
"withMultiplier": "倍率込み",
|
||||
"requestDetail": "リクエスト詳細",
|
||||
"requestNotFound": "リクエストが見つかりません",
|
||||
"basicInfo": "基本情報",
|
||||
"tokenUsage": "Token 使用量",
|
||||
"cacheCreationCost": "キャッシュ作成コスト",
|
||||
"costBreakdown": "コスト明細",
|
||||
"performance": "パフォーマンス",
|
||||
"latency": "レイテンシー",
|
||||
"errorMessage": "エラーメッセージ"
|
||||
"cacheRead": "ヒット"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "利用状況を設定",
|
||||
@@ -1044,7 +956,6 @@
|
||||
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
|
||||
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
|
||||
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
||||
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
|
||||
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
||||
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
||||
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
||||
@@ -1063,8 +974,7 @@
|
||||
"retryLater": "時間をおいて再試行してください",
|
||||
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
|
||||
"checkDiskSpace": "ディスク容量を確認してください",
|
||||
"checkPermission": "ディレクトリの権限を確認してください",
|
||||
"uninstallFirst": "同名のスキルを先にアンインストールしてください"
|
||||
"checkPermission": "ディレクトリの権限を確認してください"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
@@ -1227,8 +1137,8 @@
|
||||
"tooltip": "フェイルオーバー優先度 {{priority}}"
|
||||
},
|
||||
"tooltip": {
|
||||
"enabled": "{{app}} フェイルオーバーが有効\nキューの優先度(P1→P2→...)で使用します",
|
||||
"disabled": "{{app}} フェイルオーバーを有効にする\nキューの P1 に即時切替し、失敗時は次を順に試行します"
|
||||
"enabled": "{{app}} フェイルオーバーが有効\n次の利用可能なプロバイダーに自動切り替え",
|
||||
"disabled": "{{app}} フェイルオーバーを有効にする\n現在のプロバイダーが失敗した場合に自動切り替え"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@@ -1325,18 +1235,15 @@
|
||||
},
|
||||
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||
"failover": {
|
||||
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります",
|
||||
"autoSwitch": "自動フェイルオーバー",
|
||||
"autoSwitchDescription": "有効にするとキューの P1 に即時切り替え、リクエスト失敗時はキュー内の次のプロバイダーを自動で試行します"
|
||||
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります"
|
||||
},
|
||||
"failoverQueue": {
|
||||
"title": "フェイルオーバーキュー",
|
||||
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
|
||||
"info": "自動フェイルオーバーを有効にすると、キューの優先度順(P1 優先)でプロバイダーを使用します。失敗時はキュー内の次のプロバイダーを順に試行します。",
|
||||
"info": "現在アクティブなプロバイダーが常に優先されます。リクエストが失敗すると、システムはキュー順に他のプロバイダーを試行します。",
|
||||
"selectProvider": "キューに追加するプロバイダーを選択",
|
||||
"noAvailableProviders": "追加できるプロバイダーがありません",
|
||||
"empty": "フェイルオーバーキューが空です。自動フェイルオーバーを有効にするにはプロバイダーを追加してください。",
|
||||
"orderHint": "キューの順序はホームのプロバイダー一覧の順序と一致します。ホームでドラッグして順序を変更できます。",
|
||||
"dragHint": "ドラッグでフェイルオーバー順序を調整します。番号が小さいほど優先度が高くなります。",
|
||||
"toggleEnabled": "有効/無効",
|
||||
"addSuccess": "フェイルオーバーキューに追加しました",
|
||||
|
||||
+10
-103
@@ -265,8 +265,6 @@
|
||||
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
|
||||
"launchOnStartup": "开机自启",
|
||||
"launchOnStartupDescription": "随系统启动自动运行 CC Switch",
|
||||
"silentStartup": "静默启动",
|
||||
"silentStartupDescription": "程序启动时不显示主窗口,仅在系统托盘运行",
|
||||
"autoLaunchFailed": "设置开机自启失败",
|
||||
"minimizeToTray": "关闭时最小化到托盘",
|
||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||
@@ -274,48 +272,6 @@
|
||||
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
|
||||
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
|
||||
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
|
||||
"appVisibility": {
|
||||
"title": "主页面显示",
|
||||
"description": "选择在主页面显示的应用",
|
||||
"claudeDesc": "Anthropic Claude Code CLI",
|
||||
"codexDesc": "OpenAI Codex CLI",
|
||||
"geminiDesc": "Google Gemini CLI",
|
||||
"opencodeDesc": "OpenCode CLI"
|
||||
},
|
||||
"skillSync": {
|
||||
"title": "Skill 同步方式",
|
||||
"description": "选择 Skills 的文件同步策略",
|
||||
"symlink": "软连接",
|
||||
"copy": "文件复制",
|
||||
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "首选终端",
|
||||
"description": "选择点击终端按钮时使用的终端应用",
|
||||
"fallbackHint": "如果选择的终端不可用,将自动使用系统默认终端",
|
||||
"options": {
|
||||
"macos": {
|
||||
"terminal": "Terminal.app",
|
||||
"iterm2": "iTerm2",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
},
|
||||
"windows": {
|
||||
"cmd": "命令提示符",
|
||||
"powershell": "PowerShell",
|
||||
"wt": "Windows Terminal"
|
||||
},
|
||||
"linux": {
|
||||
"gnomeTerminal": "GNOME Terminal",
|
||||
"konsole": "Konsole",
|
||||
"xfce4Terminal": "Xfce4 Terminal",
|
||||
"alacritty": "Alacritty",
|
||||
"kitty": "Kitty",
|
||||
"ghostty": "Ghostty"
|
||||
}
|
||||
}
|
||||
},
|
||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||
"appConfigDir": "CC Switch 配置目录",
|
||||
@@ -327,12 +283,9 @@
|
||||
"codexConfigDirDescription": "覆盖 Codex 配置目录。",
|
||||
"geminiConfigDir": "Gemini 配置目录",
|
||||
"geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。",
|
||||
"opencodeConfigDir": "OpenCode 配置目录",
|
||||
"opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。",
|
||||
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
|
||||
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
|
||||
"browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini",
|
||||
"browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode",
|
||||
"browseDirectory": "浏览目录",
|
||||
"resetDefault": "恢复默认目录(需保存后生效)",
|
||||
"checkForUpdates": "检查更新",
|
||||
@@ -376,25 +329,11 @@
|
||||
"saved": "代理设置已保存",
|
||||
"saveFailed": "保存失败:{{error}}",
|
||||
"testSuccess": "连接成功!延迟 {{latency}}ms",
|
||||
"testFailed": "连接失败:{{error}}",
|
||||
"pricingDefaultsTitle": "计费默认配置",
|
||||
"pricingDefaultsDescription": "设置各应用的默认倍率与计费模式来源。",
|
||||
"pricingAppLabel": "应用",
|
||||
"defaultCostMultiplierLabel": "默认倍率",
|
||||
"defaultCostMultiplierHint": "用于成本计算的倍率,支持小数。",
|
||||
"pricingModelSourceLabel": "计费模式",
|
||||
"pricingModelSourceRequest": "请求模型",
|
||||
"pricingModelSourceResponse": "返回模型",
|
||||
"pricingSave": "保存计费配置",
|
||||
"pricingSaved": "计费配置已保存",
|
||||
"pricingSaveFailed": "保存计费配置失败:{{error}}",
|
||||
"pricingLoadFailed": "加载计费配置失败:{{error}}",
|
||||
"defaultCostMultiplierRequired": "默认倍率不能为空",
|
||||
"defaultCostMultiplierInvalid": "默认倍率格式不正确"
|
||||
"testFailed": "连接失败:{{error}}"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
@@ -446,8 +385,7 @@
|
||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
||||
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
|
||||
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
|
||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
|
||||
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!"
|
||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!"
|
||||
},
|
||||
"parameterConfig": "参数配置 - {{name}} *",
|
||||
"mainModel": "主模型 (可选)",
|
||||
@@ -526,18 +464,7 @@
|
||||
"useCustomProxy": "使用单独代理",
|
||||
"proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
|
||||
"proxyUsername": "用户名(可选)",
|
||||
"proxyPassword": "密码(可选)",
|
||||
"pricingConfig": "计费配置",
|
||||
"useCustomPricing": "使用单独配置",
|
||||
"pricingConfigDesc": "为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
|
||||
"costMultiplier": "成本倍率",
|
||||
"costMultiplierPlaceholder": "留空使用全局默认(1)",
|
||||
"costMultiplierHint": "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
|
||||
"pricingModelSourceLabel": "计费模式",
|
||||
"pricingModelSourceInherit": "继承全局默认",
|
||||
"pricingModelSourceRequest": "请求模型",
|
||||
"pricingModelSourceResponse": "返回模型",
|
||||
"pricingModelSourceHint": "选择按请求模型还是返回模型进行定价匹配"
|
||||
"proxyPassword": "密码(可选)"
|
||||
},
|
||||
"codexConfig": {
|
||||
"authJson": "auth.json (JSON) *",
|
||||
@@ -640,9 +567,6 @@
|
||||
"cacheCreationTokens": "缓存创建",
|
||||
"timingInfo": "用时/首字",
|
||||
"status": "状态",
|
||||
"multiplier": "倍率",
|
||||
"requestModel": "请求模型",
|
||||
"responseModel": "返回模型",
|
||||
"noData": "暂无数据",
|
||||
"unknownProvider": "未知供应商",
|
||||
"stream": "流",
|
||||
@@ -685,19 +609,7 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "创建",
|
||||
"cacheRead": "命中",
|
||||
"baseCost": "基础",
|
||||
"costMultiplier": "成本倍率",
|
||||
"withMultiplier": "含倍率",
|
||||
"requestDetail": "请求详情",
|
||||
"requestNotFound": "请求未找到",
|
||||
"basicInfo": "基本信息",
|
||||
"tokenUsage": "Token 使用量",
|
||||
"cacheCreationCost": "缓存写入成本",
|
||||
"costBreakdown": "成本明细",
|
||||
"performance": "性能信息",
|
||||
"latency": "延迟",
|
||||
"errorMessage": "错误信息"
|
||||
"cacheRead": "命中"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "配置用量查询",
|
||||
@@ -1044,7 +956,6 @@
|
||||
"downloadTimeoutHint": "请检查网络连接或稍后重试",
|
||||
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
|
||||
"skillDirNotFound": "技能目录不存在:{{path}}",
|
||||
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
|
||||
"emptyArchive": "下载的压缩包为空",
|
||||
"downloadFailed": "下载失败:HTTP {{status}}",
|
||||
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
||||
@@ -1063,8 +974,7 @@
|
||||
"retryLater": "请稍后重试",
|
||||
"checkRepoUrl": "请检查仓库地址和分支名称",
|
||||
"checkDiskSpace": "请检查磁盘空间",
|
||||
"checkPermission": "请检查目录权限",
|
||||
"uninstallFirst": "请先卸载已安装的同名技能"
|
||||
"checkPermission": "请检查目录权限"
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
@@ -1227,8 +1137,8 @@
|
||||
"tooltip": "故障转移优先级 {{priority}}"
|
||||
},
|
||||
"tooltip": {
|
||||
"enabled": "{{app}} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商",
|
||||
"disabled": "启用 {{app}} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个"
|
||||
"enabled": "{{app}} 故障转移已启用\n自动切换到下一个可用供应商",
|
||||
"disabled": "启用 {{app}} 故障转移\n当当前供应商失败时自动切换"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
@@ -1330,18 +1240,15 @@
|
||||
},
|
||||
"switchFailed": "切换失败: {{error}}",
|
||||
"failover": {
|
||||
"proxyRequired": "需要先启动代理服务才能配置故障转移",
|
||||
"autoSwitch": "自动故障转移",
|
||||
"autoSwitchDescription": "开启后将立即切换到队列 P1,并在请求失败时自动切换到队列中的下一个供应商"
|
||||
"proxyRequired": "需要先启动代理服务才能配置故障转移"
|
||||
},
|
||||
"failoverQueue": {
|
||||
"title": "故障转移队列",
|
||||
"description": "管理各应用的供应商故障转移顺序",
|
||||
"info": "启用自动故障转移后,将按队列优先级选择供应商(P1 优先)。当请求失败时,系统会按队列顺序依次尝试下一个供应商。",
|
||||
"info": "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
|
||||
"selectProvider": "选择供应商添加到队列",
|
||||
"noAvailableProviders": "没有可添加的供应商",
|
||||
"empty": "故障转移队列为空。添加供应商以启用自动故障转移。",
|
||||
"orderHint": "队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
|
||||
"dragHint": "拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
||||
"toggleEnabled": "启用/禁用",
|
||||
"addSuccess": "已添加到故障转移队列",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 608 B |
@@ -50,14 +50,10 @@ export const icons: Record<string, string> = {
|
||||
zeroone: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`,
|
||||
zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`,
|
||||
openrouter: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>`,
|
||||
rc: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 128 128"><title>RightCode</title><path fill="#EA6C2C" fill-rule="evenodd" d="M 67.00 124.35 L 65.00 124.65 L 63.00 124.31 L 60.87 122.00 L 60.51 120.00 L 60.62 119.00 L 61.63 117.00 L 63.64 115.00 L 63.65 23.00 L 63.40 22.00 L 60.71 20.00 L 59.73 18.00 L 59.66 16.00 L 61.18 13.00 L 63.00 11.70 L 65.00 11.36 L 67.00 11.68 L 69.00 13.07 L 70.24 15.00 L 70.43 17.00 L 69.35 20.00 L 66.75 22.00 L 66.53 23.00 L 66.56 115.00 L 69.38 118.00 L 69.69 120.00 L 69.38 122.00 L 67.00 124.35 Z M 65.38 19.00 L 66.99 18.00 L 67.30 16.00 L 66.00 14.66 L 64.00 14.69 L 62.79 16.00 L 62.66 17.00 L 64.00 18.73 L 65.38 19.00 Z M 72.00 53.03 L 71.70 52.00 L 71.56 35.00 L 70.00 33.71 L 68.79 32.00 L 68.52 30.00 L 69.00 28.15 L 70.00 26.71 L 72.00 25.59 L 74.00 25.45 L 75.00 25.70 L 76.75 27.00 L 77.50 28.00 L 78.15 30.00 L 77.18 33.00 L 74.81 35.00 L 74.60 50.00 L 72.00 53.03 Z M 74.12 32.00 L 75.11 31.00 L 75.31 30.00 L 74.77 29.00 L 74.00 28.40 L 72.00 28.65 L 71.35 30.00 L 71.45 31.00 L 73.00 32.26 L 74.12 32.00 Z M 58.00 52.97 L 55.68 50.00 L 55.65 40.00 L 52.89 37.00 L 52.36 35.00 L 52.62 33.00 L 54.00 31.03 L 57.00 29.77 L 60.00 30.84 L 61.03 32.00 L 61.64 34.00 L 61.21 37.00 L 58.49 40.00 L 58.47 52.00 L 58.00 52.97 Z M 58.43 36.00 L 59.06 35.00 L 58.91 34.00 L 58.00 32.96 L 57.00 32.66 L 56.00 33.10 L 55.29 34.00 L 55.19 35.00 L 55.64 36.00 L 57.00 36.59 L 58.43 36.00 Z M 57.00 89.45 L 46.00 89.35 L 37.24 77.00 L 36.00 75.99 L 35.00 75.93 L 28.00 75.93 L 27.12 77.00 L 27.14 88.00 L 26.80 89.00 L 26.00 89.36 L 18.00 89.36 L 16.89 89.00 L 16.66 48.00 L 17.00 46.90 L 44.00 46.73 L 48.00 47.67 L 50.00 48.57 L 53.23 51.00 L 55.44 54.00 L 56.34 56.00 L 57.07 59.00 L 56.95 64.00 L 56.15 67.00 L 54.34 70.00 L 52.00 72.26 L 48.83 74.00 L 48.06 75.00 L 57.06 88.00 L 57.53 89.00 L 57.00 89.45 Z M 110.00 89.13 L 109.00 89.65 L 90.00 89.61 L 85.00 89.18 L 80.00 87.40 L 77.00 85.39 L 74.64 83.00 L 72.64 80.00 L 70.76 75.00 L 70.20 70.00 L 70.36 65.00 L 70.81 62.00 L 72.61 57.00 L 74.49 54.00 L 77.36 51.00 L 81.00 48.62 L 84.00 47.54 L 88.00 46.77 L 109.00 46.64 L 109.79 47.00 L 110.13 48.00 L 110.00 55.15 L 109.00 55.68 L 91.00 55.74 L 87.00 56.77 L 84.11 59.00 L 82.59 61.00 L 81.36 64.00 L 80.64 69.00 L 81.62 74.00 L 82.58 76.00 L 85.00 78.66 L 88.00 80.22 L 92.00 80.64 L 109.00 80.64 L 109.80 81.00 L 110.13 82.00 L 110.00 89.13 Z M 43.29 67.00 L 44.89 66.00 L 46.00 64.70 L 46.86 62.00 L 46.50 59.00 L 45.00 56.77 L 43.00 55.56 L 40.00 55.18 L 28.00 55.17 L 27.12 56.00 L 27.16 67.00 L 28.00 67.65 L 40.00 67.63 L 43.29 67.00 Z M 74.00 105.20 L 72.00 105.09 L 70.13 104.00 L 68.71 102.00 L 68.54 101.00 L 68.69 99.00 L 70.00 97.06 L 71.42 96.00 L 71.71 95.00 L 71.70 86.00 L 72.00 84.36 L 74.56 87.00 L 74.64 95.00 L 75.00 95.97 L 77.39 98.00 L 78.12 100.00 L 77.32 103.00 L 76.00 104.37 L 74.00 105.20 Z M 59.00 111.05 L 57.00 111.40 L 55.00 111.01 L 52.89 109.00 L 52.36 107.00 L 53.00 104.45 L 55.51 102.00 L 55.65 93.00 L 55.88 92.00 L 57.00 91.64 L 58.28 92.00 L 58.49 93.00 L 58.63 102.00 L 61.00 104.14 L 61.65 106.00 L 61.26 109.00 L 59.00 111.05 Z M 74.39 102.00 L 75.23 101.00 L 75.00 99.65 L 74.00 98.65 L 73.00 98.54 L 72.06 99.00 L 71.39 100.00 L 71.39 101.00 L 72.00 101.96 L 73.00 102.38 L 74.39 102.00 Z M 58.51 108.00 L 59.09 107.00 L 58.83 106.00 L 58.00 105.18 L 57.00 104.85 L 55.33 106.00 L 55.18 107.00 L 56.00 108.39 L 57.00 108.65 L 58.51 108.00 Z M 65.09 122.00 L 66.75 121.00 L 67.14 120.00 L 66.00 118.36 L 65.00 118.16 L 63.54 119.00 L 63.28 120.00 L 63.47 121.00 L 65.09 122.00 Z"/></svg>`,
|
||||
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
|
||||
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
|
||||
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
|
||||
opencode: `<svg height="1em" width="1em" style="flex:none;line-height:1" viewBox="0 0 240 300" xmlns="http://www.w3.org/2000/svg"><title>OpenCode</title><g clip-path="url(#clip0_1401_86274)"><mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300"><path d="M240 0H0V300H240V0Z" fill="white"/></mask><g mask="url(#mask0_1401_86274)"><path d="M180 240H60V120H180V240Z" fill="#CFCECD"/><path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/></g></g><defs><clipPath id="clip0_1401_86274"><rect width="240" height="300" fill="white"/></clipPath></defs></svg>`,
|
||||
siliconflow: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>`,
|
||||
catcoder: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>KwaiKAT</title><path d="M20.42 19.311h3.418V1l-6.781 4.177-6.778-4.111.026 7.868h3.418l-.026-2.222 3.42 2.035 3.303-2.035v12.6z"></path><path d="M3.064 10.734c2.784-2.07 6.942-2.394 9.941.907l.01.01.01.013 9.16 12.24h-3.84l-7.69-10.217c-1.63-1.737-3.891-1.689-5.515-.638-1.624 1.05-2.563 3.073-1.548 5.28 1.494 3.246 6.152 3.275 7.725.108l.032-.064 2.02 2.629c-2.98 3.968-9.329 3.926-12.165-.552-2.395-3.78-.926-7.645 1.86-9.716z"></path></svg>`,
|
||||
mcp: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>`,
|
||||
};
|
||||
|
||||
export const iconList = Object.keys(icons);
|
||||
@@ -69,5 +65,3 @@ export function getIcon(name: string): string {
|
||||
export function hasIcon(name: string): boolean {
|
||||
return name.toLowerCase() in icons;
|
||||
}
|
||||
|
||||
export { getIconMetadata } from "./metadata";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 978 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><path fill="#EA6C2C" fill-rule="evenodd" d="M 67.00 124.35 L 65.00 124.65 L 63.00 124.31 L 60.87 122.00 L 60.51 120.00 L 60.62 119.00 L 61.63 117.00 L 63.64 115.00 L 63.65 23.00 L 63.40 22.00 L 60.71 20.00 L 59.73 18.00 L 59.66 16.00 L 61.18 13.00 L 63.00 11.70 L 65.00 11.36 L 67.00 11.68 L 69.00 13.07 L 70.24 15.00 L 70.43 17.00 L 69.35 20.00 L 66.75 22.00 L 66.53 23.00 L 66.56 115.00 L 69.38 118.00 L 69.69 120.00 L 69.38 122.00 L 67.00 124.35 Z M 65.38 19.00 L 66.99 18.00 L 67.30 16.00 L 66.00 14.66 L 64.00 14.69 L 62.79 16.00 L 62.66 17.00 L 64.00 18.73 L 65.38 19.00 Z M 72.00 53.03 L 71.70 52.00 L 71.56 35.00 L 70.00 33.71 L 68.79 32.00 L 68.52 30.00 L 69.00 28.15 L 70.00 26.71 L 72.00 25.59 L 74.00 25.45 L 75.00 25.70 L 76.75 27.00 L 77.50 28.00 L 78.15 30.00 L 77.18 33.00 L 74.81 35.00 L 74.60 50.00 L 72.00 53.03 Z M 74.12 32.00 L 75.11 31.00 L 75.31 30.00 L 74.77 29.00 L 74.00 28.40 L 72.00 28.65 L 71.35 30.00 L 71.45 31.00 L 73.00 32.26 L 74.12 32.00 Z M 58.00 52.97 L 55.68 50.00 L 55.65 40.00 L 52.89 37.00 L 52.36 35.00 L 52.62 33.00 L 54.00 31.03 L 57.00 29.77 L 60.00 30.84 L 61.03 32.00 L 61.64 34.00 L 61.21 37.00 L 58.49 40.00 L 58.47 52.00 L 58.00 52.97 Z M 58.43 36.00 L 59.06 35.00 L 58.91 34.00 L 58.00 32.96 L 57.00 32.66 L 56.00 33.10 L 55.29 34.00 L 55.19 35.00 L 55.64 36.00 L 57.00 36.59 L 58.43 36.00 Z M 57.00 89.45 L 46.00 89.35 L 37.24 77.00 L 36.00 75.99 L 35.00 75.93 L 28.00 75.93 L 27.12 77.00 L 27.14 88.00 L 26.80 89.00 L 26.00 89.36 L 18.00 89.36 L 16.89 89.00 L 16.66 48.00 L 17.00 46.90 L 44.00 46.73 L 48.00 47.67 L 50.00 48.57 L 53.23 51.00 L 55.44 54.00 L 56.34 56.00 L 57.07 59.00 L 56.95 64.00 L 56.15 67.00 L 54.34 70.00 L 52.00 72.26 L 48.83 74.00 L 48.06 75.00 L 57.06 88.00 L 57.53 89.00 L 57.00 89.45 Z M 110.00 89.13 L 109.00 89.65 L 90.00 89.61 L 85.00 89.18 L 80.00 87.40 L 77.00 85.39 L 74.64 83.00 L 72.64 80.00 L 70.76 75.00 L 70.20 70.00 L 70.36 65.00 L 70.81 62.00 L 72.61 57.00 L 74.49 54.00 L 77.36 51.00 L 81.00 48.62 L 84.00 47.54 L 88.00 46.77 L 109.00 46.64 L 109.79 47.00 L 110.13 48.00 L 110.00 55.15 L 109.00 55.68 L 91.00 55.74 L 87.00 56.77 L 84.11 59.00 L 82.59 61.00 L 81.36 64.00 L 80.64 69.00 L 81.62 74.00 L 82.58 76.00 L 85.00 78.66 L 88.00 80.22 L 92.00 80.64 L 109.00 80.64 L 109.80 81.00 L 110.13 82.00 L 110.00 89.13 Z M 43.29 67.00 L 44.89 66.00 L 46.00 64.70 L 46.86 62.00 L 46.50 59.00 L 45.00 56.77 L 43.00 55.56 L 40.00 55.18 L 28.00 55.17 L 27.12 56.00 L 27.16 67.00 L 28.00 67.65 L 40.00 67.63 L 43.29 67.00 Z M 74.00 105.20 L 72.00 105.09 L 70.13 104.00 L 68.71 102.00 L 68.54 101.00 L 68.69 99.00 L 70.00 97.06 L 71.42 96.00 L 71.71 95.00 L 71.70 86.00 L 72.00 84.36 L 74.56 87.00 L 74.64 95.00 L 75.00 95.97 L 77.39 98.00 L 78.12 100.00 L 77.32 103.00 L 76.00 104.37 L 74.00 105.20 Z M 59.00 111.05 L 57.00 111.40 L 55.00 111.01 L 52.89 109.00 L 52.36 107.00 L 53.00 104.45 L 55.51 102.00 L 55.65 93.00 L 55.88 92.00 L 57.00 91.64 L 58.28 92.00 L 58.49 93.00 L 58.63 102.00 L 61.00 104.14 L 61.65 106.00 L 61.26 109.00 L 59.00 111.05 Z M 74.39 102.00 L 75.23 101.00 L 75.00 99.65 L 74.00 98.65 L 73.00 98.54 L 72.06 99.00 L 71.39 100.00 L 71.39 101.00 L 72.00 101.96 L 73.00 102.38 L 74.39 102.00 Z M 58.51 108.00 L 59.09 107.00 L 58.83 106.00 L 58.00 105.18 L 57.00 104.85 L 55.33 106.00 L 55.18 107.00 L 56.00 108.39 L 57.00 108.65 L 58.51 108.00 Z M 65.09 122.00 L 66.75 121.00 L 67.14 120.00 L 66.00 118.36 L 65.00 118.16 L 63.54 119.00 L 63.28 120.00 L 63.47 121.00 L 65.09 122.00 Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>
|
||||
|
Before Width: | Height: | Size: 520 B |
@@ -92,29 +92,4 @@ export const proxyApi = {
|
||||
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
|
||||
return invoke("update_proxy_config_for_app", { config });
|
||||
},
|
||||
|
||||
// ========== 计费默认配置 API ==========
|
||||
|
||||
// 获取默认成本倍率
|
||||
async getDefaultCostMultiplier(appType: string): Promise<string> {
|
||||
return invoke("get_default_cost_multiplier", { appType });
|
||||
},
|
||||
|
||||
// 设置默认成本倍率
|
||||
async setDefaultCostMultiplier(
|
||||
appType: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
return invoke("set_default_cost_multiplier", { appType, value });
|
||||
},
|
||||
|
||||
// 获取计费模式来源
|
||||
async getPricingModelSource(appType: string): Promise<string> {
|
||||
return invoke("get_pricing_model_source", { appType });
|
||||
},
|
||||
|
||||
// 设置计费模式来源
|
||||
async setPricingModelSource(appType: string, value: string): Promise<void> {
|
||||
return invoke("set_pricing_model_source", { appType, value });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@ function getErrorI18nKey(code: string): string {
|
||||
DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout",
|
||||
DOWNLOAD_FAILED: "skills.error.downloadFailed",
|
||||
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
||||
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
||||
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
||||
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
||||
};
|
||||
@@ -53,7 +52,6 @@ function getSuggestionI18nKey(suggestion: string): string {
|
||||
retryLater: "skills.error.suggestion.retryLater",
|
||||
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
|
||||
checkPermission: "skills.error.suggestion.checkPermission",
|
||||
uninstallFirst: "skills.error.suggestion.uninstallFirst",
|
||||
http403: "skills.error.http403",
|
||||
http404: "skills.error.http404",
|
||||
http429: "skills.error.http429",
|
||||
|
||||
@@ -268,21 +268,6 @@ export function useSetAutoFailoverEnabled() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["autoFailoverEnabled", variables.appType],
|
||||
});
|
||||
// 启用/关闭故障转移可能触发:
|
||||
// - 立即切到队列 P1(当前供应商变化)
|
||||
// - 队列为空时自动把当前供应商加入队列(队列内容变化)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["failoverQueue", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["availableProvidersForFailover", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providers", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["proxyStatus"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@ export const settingsSchema = z.object({
|
||||
currentProviderClaude: z.string().optional(),
|
||||
currentProviderCodex: z.string().optional(),
|
||||
currentProviderGemini: z.string().optional(),
|
||||
|
||||
// Skill 同步设置
|
||||
skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(),
|
||||
});
|
||||
|
||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
||||
|
||||
@@ -135,21 +135,6 @@ export interface ProviderMeta {
|
||||
testConfig?: ProviderTestConfig;
|
||||
// 供应商单独的代理配置
|
||||
proxyConfig?: ProviderProxyConfig;
|
||||
// 供应商成本倍率
|
||||
costMultiplier?: string;
|
||||
// 供应商计费模式来源
|
||||
pricingModelSource?: string;
|
||||
}
|
||||
|
||||
// Skill 同步方式
|
||||
export type SkillSyncMethod = "auto" | "symlink" | "copy";
|
||||
|
||||
// 主页面显示的应用配置
|
||||
export interface VisibleApps {
|
||||
claude: boolean;
|
||||
codex: boolean;
|
||||
gemini: boolean;
|
||||
opencode: boolean;
|
||||
}
|
||||
|
||||
// 应用设置类型(用于设置对话框与 Tauri API)
|
||||
@@ -166,14 +151,9 @@ export interface Settings {
|
||||
skipClaudeOnboarding?: boolean;
|
||||
// 是否开机自启
|
||||
launchOnStartup?: boolean;
|
||||
// 静默启动(程序启动时不显示主窗口)
|
||||
silentStartup?: boolean;
|
||||
// 首选语言(可选,默认中文)
|
||||
language?: "en" | "zh" | "ja";
|
||||
|
||||
// 主页面显示的应用(默认全部显示)
|
||||
visibleApps?: VisibleApps;
|
||||
|
||||
// ===== 设备级目录覆盖 =====
|
||||
// 覆盖 Claude Code 配置目录(可选)
|
||||
claudeConfigDir?: string;
|
||||
@@ -191,17 +171,6 @@ export interface Settings {
|
||||
currentProviderCodex?: string;
|
||||
// 当前 Gemini 供应商 ID(优先于数据库 is_current)
|
||||
currentProviderGemini?: string;
|
||||
|
||||
// ===== Skill 同步设置 =====
|
||||
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||
skillSyncMethod?: SkillSyncMethod;
|
||||
|
||||
// ===== 终端设置 =====
|
||||
// 首选终端应用(可选,默认使用系统默认终端)
|
||||
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||||
// Windows: "cmd" | "powershell" | "wt"
|
||||
// Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
|
||||
preferredTerminal?: string;
|
||||
}
|
||||
|
||||
// MCP 服务器连接参数(宽松:允许扩展字段)
|
||||
@@ -330,8 +299,6 @@ export interface OpenCodeModel {
|
||||
output?: number;
|
||||
};
|
||||
options?: Record<string, unknown>; // 模型级别额外选项(provider 路由等)
|
||||
// 支持任意额外字段(cost、modalities、thinking、variants 等)
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// OpenCode 供应商选项
|
||||
|
||||
@@ -13,8 +13,6 @@ export interface RequestLog {
|
||||
providerName?: string;
|
||||
appType: string;
|
||||
model: string;
|
||||
requestModel?: string;
|
||||
costMultiplier: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export function isTextEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
const tagName = target.tagName;
|
||||
return (
|
||||
tagName === "INPUT" ||
|
||||
tagName === "TEXTAREA" ||
|
||||
tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mutateAsyncMock = vi.fn();
|
||||
const testMutateAsyncMock = vi.fn();
|
||||
const scanMutateAsyncMock = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/useGlobalProxy", () => ({
|
||||
useGlobalProxyUrl: () => ({ data: "http://127.0.0.1:7890", isLoading: false }),
|
||||
useSetGlobalProxyUrl: () => ({
|
||||
mutateAsync: mutateAsyncMock,
|
||||
isPending: false,
|
||||
}),
|
||||
useTestProxy: () => ({
|
||||
mutateAsync: testMutateAsyncMock,
|
||||
isPending: false,
|
||||
}),
|
||||
useScanProxies: () => ({
|
||||
mutateAsync: scanMutateAsyncMock,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("GlobalProxySettings", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsyncMock.mockReset();
|
||||
testMutateAsyncMock.mockReset();
|
||||
scanMutateAsyncMock.mockReset();
|
||||
});
|
||||
|
||||
it("renders proxy URL input with saved value", async () => {
|
||||
render(<GlobalProxySettings />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
|
||||
);
|
||||
// URL 对象会在末尾添加斜杠
|
||||
await waitFor(() =>
|
||||
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves proxy URL when save button is clicked", async () => {
|
||||
render(<GlobalProxySettings />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
|
||||
);
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: "http://localhost:8080" } });
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "common.save" });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled());
|
||||
// 没有用户名时,URL 不经过 URL 对象解析,所以没有尾部斜杠
|
||||
expect(mutateAsyncMock).toHaveBeenCalledWith("http://localhost:8080");
|
||||
});
|
||||
|
||||
it("clears proxy URL when clear button is clicked", async () => {
|
||||
render(<GlobalProxySettings />);
|
||||
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"http://127.0.0.1:7890 / socks5://127.0.0.1:1080",
|
||||
);
|
||||
|
||||
// Wait for initial value to load
|
||||
await waitFor(() =>
|
||||
expect(urlInput).toHaveValue("http://127.0.0.1:7890/"),
|
||||
);
|
||||
|
||||
// Click clear button
|
||||
const clearButton = screen.getByTitle("settings.globalProxy.clear");
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(urlInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { ReactElement } from "react";
|
||||
import type { Provider } from "@/types";
|
||||
import { ProviderList } from "@/components/providers/ProviderList";
|
||||
|
||||
@@ -110,16 +108,6 @@ function createProvider(overrides: Partial<Provider> = {}): Provider {
|
||||
};
|
||||
}
|
||||
|
||||
function renderWithQueryClient(ui: ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDragSortMock.mockReset();
|
||||
useSortableMock.mockReset();
|
||||
@@ -143,7 +131,7 @@ beforeEach(() => {
|
||||
|
||||
describe("ProviderList Component", () => {
|
||||
it("should render skeleton placeholders when loading", () => {
|
||||
const { container } = renderWithQueryClient(
|
||||
const { container } = render(
|
||||
<ProviderList
|
||||
providers={{}}
|
||||
currentProviderId=""
|
||||
@@ -171,7 +159,7 @@ describe("ProviderList Component", () => {
|
||||
handleDragEnd: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithQueryClient(
|
||||
render(
|
||||
<ProviderList
|
||||
providers={{}}
|
||||
currentProviderId=""
|
||||
@@ -210,7 +198,7 @@ describe("ProviderList Component", () => {
|
||||
handleDragEnd: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithQueryClient(
|
||||
render(
|
||||
<ProviderList
|
||||
providers={{ a: providerA, b: providerB }}
|
||||
currentProviderId="b"
|
||||
@@ -274,7 +262,7 @@ describe("ProviderList Component", () => {
|
||||
handleDragEnd: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithQueryClient(
|
||||
render(
|
||||
<ProviderList
|
||||
providers={{ alpha: providerAlpha, beta: providerBeta }}
|
||||
currentProviderId=""
|
||||
|
||||
@@ -64,12 +64,9 @@ describe("useDirectorySettings", () => {
|
||||
);
|
||||
|
||||
getAppConfigDirOverrideMock.mockResolvedValue(null);
|
||||
getConfigDirMock.mockImplementation(async (app: string) => {
|
||||
if (app === "claude") return "/remote/claude";
|
||||
if (app === "codex") return "/remote/codex";
|
||||
if (app === "gemini") return "/remote/gemini";
|
||||
return "/remote/opencode";
|
||||
});
|
||||
getConfigDirMock.mockImplementation(async (app: string) =>
|
||||
app === "claude" ? "/remote/claude" : "/remote/codex",
|
||||
);
|
||||
selectConfigDirectoryMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -87,8 +84,7 @@ describe("useDirectorySettings", () => {
|
||||
appConfig: "/override/app",
|
||||
claude: "/remote/claude",
|
||||
codex: "/remote/codex",
|
||||
gemini: "/remote/gemini",
|
||||
opencode: "/remote/opencode",
|
||||
gemini: "/remote/codex", // Gemini 使用 codex 作为默认
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,17 +214,10 @@ describe("useDirectorySettings", () => {
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
act(() => {
|
||||
result.current.resetAllDirectories(
|
||||
"/server/claude",
|
||||
"/server/codex",
|
||||
"/server/gemini",
|
||||
"/server/opencode",
|
||||
);
|
||||
result.current.resetAllDirectories("/server/claude", "/server/codex");
|
||||
});
|
||||
|
||||
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
|
||||
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
|
||||
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
|
||||
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,7 +381,6 @@ describe("useSettings hook", () => {
|
||||
"/server/claude",
|
||||
undefined,
|
||||
undefined, // geminiConfigDir
|
||||
undefined, // opencodeConfigDir
|
||||
);
|
||||
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
if (
|
||||
typeof globalThis.localStorage === "undefined" ||
|
||||
typeof globalThis.localStorage?.getItem !== "function"
|
||||
) {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
},
|
||||
key: (index: number) => Array.from(storage.keys())[index] ?? null,
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./tests/setupGlobals.ts", "./tests/setupTests.ts"],
|
||||
setupFiles: ["./tests/setupTests.ts"],
|
||||
globals: true,
|
||||
coverage: {
|
||||
reporter: ["text", "lcov"],
|
||||
|
||||
Reference in New Issue
Block a user