Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73aa95263c | |||
| e1db104009 | |||
| bf9228093b | |||
| 420b5d4389 | |||
| af9785f566 | |||
| a20fb1afcf | |||
| 760d564817 | |||
| 1121347a45 | |||
| a64e588cf8 | |||
| ab08b906ad | |||
| 26753f3d66 | |||
| d783cbe745 | |||
| d304346514 | |||
| 1efa41082c | |||
| 72412e7abd | |||
| b496e250dd | |||
| 087622bf6d | |||
| 1e95a3f16b | |||
| bfcdde5011 | |||
| b1e4c37f9c | |||
| 39964b5733 | |||
| 2298119177 | |||
| d356f1e208 | |||
| 434ffd559d | |||
| 38e8949152 | |||
| 96ec6c561e | |||
| 6423e2e8ab | |||
| 05973bf959 | |||
| c0122d717c | |||
| 74a2e9c08b | |||
| 169db5b6d8 | |||
| 3230b0e094 | |||
| 2347ac0ee0 | |||
| d12347d837 | |||
| fef4c239c4 | |||
| 58c5468bf6 | |||
| a35d112cd4 | |||
| 665d34609d | |||
| 0eec47fd64 | |||
| e9cb7098a5 | |||
| 247973b37e | |||
| 94b83bfa3a | |||
| 98084d61aa | |||
| a627e1bb50 | |||
| c7ef4365b8 | |||
| 4c94e70f97 | |||
| 04a588694b | |||
| f5f7dfed8c | |||
| 5888c56f2a | |||
| c5cadb73bc | |||
| 17948ee031 | |||
| 2643595012 | |||
| 6ac4d1652c | |||
| 3bf37cf0ff | |||
| 526c7406fa | |||
| 9db85dd4dc | |||
| 0f333d9e5b | |||
| ba875552a6 | |||
| 75e7f9d731 | |||
| 7e6074a9a9 | |||
| c229c47c00 | |||
| 6c477a60f9 | |||
| 7eac809689 | |||
| dafa77897b | |||
| 0f959112b1 | |||
| f4c284f86c | |||
| 3878a16c4f | |||
| 00f78e4546 | |||
| 1ee1e9cb2e | |||
| 7db4b8d976 | |||
| 924ad44e6c | |||
| eecd6a3a2b |
@@ -17,3 +17,4 @@ GEMINI.md
|
||||
/.idea
|
||||
/.vscode
|
||||
vitest-report.json
|
||||
nul
|
||||
|
||||
@@ -5,6 +5,46 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.8.0] - 2025-11-28
|
||||
|
||||
### Major Updates
|
||||
|
||||
- **Persistence architecture upgrade** - Moved from single JSON storage to SQLite + JSON dual-layer; added schema versioning, transactions, and SQL import/export; first launch auto-migrates `config.json` to SQLite while keeping originals safe.
|
||||
- **Brand new UI** - Full layout redesign, unified component/ConfirmDialog styles, smoother animations, overscroll disabled; Tailwind CSS downgraded to v3.4 for compatibility.
|
||||
- **Japanese language support** - UI now localized in Chinese/English/Japanese.
|
||||
|
||||
### Added
|
||||
|
||||
- **Skills recursive scanning** - Discovers nested `SKILL.md` files across multi-level directories; same-name skills allowed by full-path dedup.
|
||||
- **Provider icons** - Presets ship with default icons; custom icon colors; icons retained when duplicating providers.
|
||||
- **Auto launch on startup** - One-click enable/disable using Registry/LaunchAgent/XDG autostart.
|
||||
- **Provider preset** - Added MiniMax partner preset.
|
||||
- **Form validation** - Required fields get real-time validation and unified toast messaging.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Custom endpoints loss** - Switched provider updates to `UPDATE` to avoid cascade deletes from `INSERT OR REPLACE`.
|
||||
- **Gemini config writing** - Correctly writes custom env vars to `.env` and keeps auth configs isolated.
|
||||
- **Provider validation** - Handles missing current provider IDs and preserves icon fields on duplicate.
|
||||
- **Linux rendering** - Fixed WebKitGTK DMA-BUF rendering and preserved user `.desktop` customizations.
|
||||
- **Misc** - Removed redundant usage queries; corrected DMXAPI auth token field; restored missing deeplink translations; fixed usage script template init.
|
||||
|
||||
### Technical
|
||||
|
||||
- **Database modules** - Added `schema`, `backup`, `migration`, and DAO layers for providers/MCP/prompts/skills/settings.
|
||||
- **Service modularization** - Split provider service into live/auth/endpoints/usage modules; deeplink parsing/import logic modularized.
|
||||
- **Code cleanup** - Removed legacy JSON-era import/export, unused MCP types; unified error handling; tests migrated to SQLite backend and MSW handlers updated.
|
||||
|
||||
### Migration Notes
|
||||
|
||||
- First launch auto-migrates data from `config.json` to SQLite and device settings to `settings.json`; originals kept; error dialog on failure; dry-run supported.
|
||||
|
||||
### Stats
|
||||
|
||||
- 51 commits since v3.7.1; 207 files changed; +17,297 / -6,870 lines. See [release-note-v3.8.0](docs/release-note-v3.8.0-en.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## [3.7.1] - 2025-11-22
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
|
||||
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
|
||||
|
||||
**From Provider Switcher to All-in-One AI CLI Management Platform**
|
||||
|
||||
@@ -51,9 +51,42 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
|
||||
|
||||
## Features
|
||||
|
||||
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md)
|
||||
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
**v3.7.0 Major Update (2025-11-19)**
|
||||
**v3.8.0 Major Update (2025-11-28)**
|
||||
|
||||
**Persistence Architecture Upgrade & Brand New UI**
|
||||
|
||||
- **SQLite + JSON Dual-layer Architecture**
|
||||
- Migrated from JSON file storage to SQLite + JSON dual-layer structure
|
||||
- Syncable data (providers, MCP, Prompts, Skills) stored in SQLite
|
||||
- Device-level data (window state, local paths) stored in JSON
|
||||
- Lays the foundation for future cloud sync functionality
|
||||
- Schema version management for database migrations
|
||||
|
||||
- **Brand New User Interface**
|
||||
- Completely redesigned interface layout
|
||||
- Unified component styles and smoother animations
|
||||
- Optimized visual hierarchy
|
||||
- Tailwind CSS downgraded from v4 to v3.4 for better browser compatibility
|
||||
|
||||
- **Japanese Language Support**
|
||||
- Added Japanese interface support (now supports Chinese/English/Japanese)
|
||||
|
||||
- **Auto Launch on Startup**
|
||||
- One-click enable/disable in settings
|
||||
- Platform-native APIs (Registry/LaunchAgent/XDG autostart)
|
||||
|
||||
- **Skills Recursive Scanning**
|
||||
- Support for multi-level directory structures
|
||||
- Allow same-named skills from different repositories
|
||||
|
||||
- **Critical Bug Fixes**
|
||||
- Fixed custom endpoints lost when updating providers
|
||||
- Fixed Gemini configuration write issues
|
||||
- Fixed Linux WebKitGTK rendering issues
|
||||
|
||||
**v3.7.0 Highlights**
|
||||
|
||||
**Six Core Features, 18,000+ Lines of New Code**
|
||||
|
||||
@@ -226,10 +259,10 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
||||
- MCP servers: `~/.gemini/settings.json` → `mcpServers`
|
||||
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
|
||||
|
||||
**CC Switch Storage**
|
||||
**CC Switch Storage (v3.8.0 New Architecture)**
|
||||
|
||||
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.)
|
||||
- Settings: `~/.cc-switch/settings.json`
|
||||
- Database (SSOT): `~/.cc-switch/cc-switch.db` (SQLite, stores providers, MCP, Prompts, Skills)
|
||||
- Local settings: `~/.cc-switch/settings.json` (device-level settings)
|
||||
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)
|
||||
|
||||
### Cloud Sync Setup
|
||||
@@ -265,11 +298,12 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
|
||||
|
||||
**Core Design Patterns**
|
||||
|
||||
- **SSOT** (Single Source of Truth): All provider configs stored in `~/.cc-switch/config.json`
|
||||
- **SSOT** (Single Source of Truth): All data stored in `~/.cc-switch/cc-switch.db` (SQLite)
|
||||
- **Dual-layer Storage**: SQLite for syncable data, JSON for device-level settings
|
||||
- **Dual-way Sync**: Write to live files on switch, backfill from live when editing active provider
|
||||
- **Atomic Writes**: Temp file + rename pattern prevents config corruption
|
||||
- **Concurrency Safe**: RwLock with scoped guards avoids deadlocks
|
||||
- **Layered Architecture**: Clear separation (Commands → Services → Models)
|
||||
- **Concurrency Safe**: Mutex-protected database connection avoids race conditions
|
||||
- **Layered Architecture**: Clear separation (Commands → Services → DAO → Database)
|
||||
|
||||
**Key Components**
|
||||
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<div align="center">
|
||||
|
||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
**プロバイダスイッチャーから AI CLI 一体型管理プラットフォームへ**
|
||||
|
||||
Claude Code・Codex・Gemini CLI のプロバイダ設定、MCP サーバー、Skills 拡張、システムプロンプトを統合管理。
|
||||
|
||||
</div>
|
||||
|
||||
## ❤️スポンサー
|
||||
|
||||

|
||||
|
||||
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。
|
||||
|
||||
GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。
|
||||
|
||||
[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
||||
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
| メイン画面 | プロバイダ追加 |
|
||||
| :-------------------------------------------: | :----------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
## 特長
|
||||
|
||||
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||
|
||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||
|
||||
**永続化アーキテクチャ刷新 & 新 UI**
|
||||
|
||||
- **SQLite + JSON 二層構造**
|
||||
- JSON 単独保存から SQLite + JSON の二層構造へ移行
|
||||
- 同期対象データ(プロバイダ、MCP、Prompts、Skills)は SQLite に保存
|
||||
- デバイス固有データ(ウィンドウ状態、ローカルパス)は JSON に保存
|
||||
- 将来のクラウド同期の土台を用意
|
||||
- DB マイグレーション用にスキーマバージョンを管理
|
||||
|
||||
- **新しいユーザーインターフェース**
|
||||
- レイアウトを全面再設計
|
||||
- コンポーネントスタイルとアニメーションを統一
|
||||
- 視覚的な階層を最適化
|
||||
- ブラウザ互換性向上のため Tailwind CSS を v4 から v3.4 にダウングレード
|
||||
|
||||
- **日本語対応**
|
||||
- UI が中国語/英語/日本語の 3 言語対応に
|
||||
|
||||
- **自動起動**
|
||||
- 設定画面でワンクリック ON/OFF
|
||||
- プラットフォームネイティブ API(Registry/LaunchAgent/XDG autostart)を使用
|
||||
|
||||
- **Skills 再帰スキャン**
|
||||
- 多階層ディレクトリをサポート
|
||||
- リポジトリが異なる同名スキルを許可
|
||||
|
||||
- **重要なバグ修正**
|
||||
- プロバイダ更新時にカスタムエンドポイントが失われる問題を修正
|
||||
- Gemini 設定の書き込み問題を修正
|
||||
- Linux WebKitGTK の描画問題を修正
|
||||
|
||||
**v3.7.0 ハイライト**
|
||||
|
||||
**6 つのコア機能、18,000 行超の新コード**
|
||||
|
||||
- **Gemini CLI 統合**
|
||||
- Claude Code / Codex / Gemini の 3 番目のサポート AI CLI
|
||||
- 2 つの設定ファイル(`.env` + `settings.json`)に対応
|
||||
- MCP サーバー管理を完備
|
||||
- プリセット:Google 公式(OAuth)/ PackyCode / カスタム
|
||||
|
||||
- **Claude Skills 管理システム**
|
||||
- GitHub リポジトリを自動スキャン(3 つのキュレーション済みリポジトリを同梱)
|
||||
- `~/.claude/skills/` へワンクリックでインストール/アンインストール
|
||||
- カスタムリポジトリ + サブディレクトリスキャンをサポート
|
||||
- ライフサイクル管理(検出/インストール/更新)を完備
|
||||
|
||||
- **Prompts 管理システム**
|
||||
- 無制限のシステムプロンプトプリセットを作成
|
||||
- Markdown エディタ(CodeMirror 6 + リアルタイムプレビュー)付き
|
||||
- スマートなバックフィル保護で手動変更を保持
|
||||
- 複数アプリに同時対応(Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
|
||||
|
||||
- **MCP v3.7.0 統合アーキテクチャ**
|
||||
- 1 つのパネルで 3 アプリの MCP を管理
|
||||
- 新たに SSE(Server-Sent Events)トランスポートを追加
|
||||
- スマート JSON パーサー + Codex TOML 自動修正
|
||||
- 双方向のインポート/エクスポート + 双方向同期
|
||||
|
||||
- **ディープリンクプロトコル**
|
||||
- `ccswitch://` を全プラットフォームで登録
|
||||
- 共有リンクからプロバイダ設定をワンクリックでインポート
|
||||
- セキュリティ検証 + ライフサイクル統合
|
||||
|
||||
- **環境変数の競合検知**
|
||||
- Claude/Codex/Gemini/MCP 間の設定競合を自動検出
|
||||
- 競合表示 + 解決ガイド
|
||||
- 上書き前の警告 + バックアップ
|
||||
|
||||
**コア機能**
|
||||
|
||||
- **プロバイダ管理**:Claude Code、Codex、Gemini の API 設定をワンクリックで切り替え
|
||||
- **速度テスト**:エンドポイント遅延を計測し、品質を可視化
|
||||
- **インポート/エクスポート**:設定をバックアップ・復元(最新 10 件を自動ローテーション)
|
||||
- **多言語対応**:UI/エラー/トレイを含む中国語・英語・日本語ローカライズ
|
||||
- **Claude プラグイン同期**:Claude プラグイン設定をワンクリックで適用/復元
|
||||
|
||||
**v3.6 ハイライト**
|
||||
|
||||
- プロバイダの複製とドラッグ&ドロップ並び替え
|
||||
- 複数エンドポイント管理とカスタム設定ディレクトリ(クラウド同期準備済み)
|
||||
- 4 階層のモデル設定(Haiku/Sonnet/Opus/Custom)
|
||||
- WSL 環境をサポートし、ディレクトリ変更時に自動同期
|
||||
- Hooks テスト 100% カバレッジ + アーキテクチャ全面リファクタ
|
||||
|
||||
**システム機能**
|
||||
|
||||
- クイックスイッチ付きシステムトレイ
|
||||
- シングルインスタンス常駐
|
||||
- ビルトイン自動アップデータ
|
||||
- ロールバック保護付きのアトミック書き込み
|
||||
|
||||
## ダウンロード & インストール
|
||||
|
||||
### システム要件
|
||||
|
||||
- **Windows**: Windows 10 以上
|
||||
- **macOS**: macOS 10.15 (Catalina) 以上
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション
|
||||
|
||||
### Windows ユーザー
|
||||
|
||||
[Releases](../../releases) ページから最新版の `CC-Switch-v{version}-Windows.msi` インストーラー、またはポータブル版 `CC-Switch-v{version}-Windows-Portable.zip` をダウンロード。
|
||||
|
||||
### macOS ユーザー
|
||||
|
||||
**方法 1: Homebrew でインストール(推奨)**
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
アップデート:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
**方法 2: 手動ダウンロード**
|
||||
|
||||
[Releases](../../releases) から `CC-Switch-v{version}-macOS.zip` をダウンロードして展開。
|
||||
|
||||
> **注意**: 開発者アカウント未登録のため、初回起動時に「開発元を確認できません」と表示される場合があります。一度閉じてから「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックしてください。以降は通常通り起動できます。
|
||||
|
||||
### ArchLinux ユーザー
|
||||
|
||||
**paru でインストール(推奨)**
|
||||
|
||||
```bash
|
||||
paru -S cc-switch-bin
|
||||
```
|
||||
|
||||
### Linux ユーザー
|
||||
|
||||
[Releases](../../releases) から最新版の `CC-Switch-v{version}-Linux.deb` または `CC-Switch-v{version}-Linux.AppImage` をダウンロード。
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### 基本的な使い方
|
||||
|
||||
1. **プロバイダ追加**:「Add Provider」をクリック → プリセットを選ぶかカスタム設定を作成
|
||||
2. **プロバイダ切り替え**:
|
||||
- メイン UI: プロバイダを選択 → 「Enable」をクリック
|
||||
- システムトレイ: プロバイダ名をクリック(即時反映)
|
||||
3. **反映**: ターミナルや Claude Code / Codex / Gemini クライアントを再起動して適用
|
||||
4. **公式設定に戻す**: 「Official Login」プリセット(Claude/Codex)または「Google Official」プリセット(Gemini)を選び、対応クライアントを再起動してログイン/OAuth を実行
|
||||
|
||||
### MCP 管理
|
||||
|
||||
- **入口**: 右上の「MCP」ボタンをクリック
|
||||
- **サーバー追加**:
|
||||
- 組み込みテンプレート(mcp-fetch、mcp-filesystem など)を使用
|
||||
- stdio / http / sse の各トランスポートをサポート
|
||||
- アプリごとに独立した MCP を設定可能
|
||||
- **有効/無効**: トグルでライブ設定への同期を切り替え
|
||||
- **同期**: 有効なサーバーは各アプリのライブファイルへ自動同期
|
||||
- **インポート/エクスポート**: Claude/Codex/Gemini の設定ファイルから既存 MCP を取り込み
|
||||
|
||||
### Skills 管理 (v3.7.0 新機能)
|
||||
|
||||
- **入口**: 右上の「Skills」ボタンをクリック
|
||||
- **スキル探索**:
|
||||
- 事前設定済みの GitHub リポジトリを自動スキャン(Anthropic 公式、ComposioHQ、コミュニティなど)
|
||||
- カスタムリポジトリを追加(サブディレクトリスキャン対応)
|
||||
- **インストール**: 「Install」を押すだけで `~/.claude/skills/` に配置
|
||||
- **アンインストール**: 「Uninstall」で安全に削除と状態クリーンアップ
|
||||
- **リポジトリ管理**: カスタム GitHub リポジトリを追加/削除
|
||||
|
||||
### Prompts 管理 (v3.7.0 新機能)
|
||||
|
||||
- **入口**: 右上の「Prompts」ボタンをクリック
|
||||
- **プリセット作成**:
|
||||
- 無制限のシステムプロンプトプリセットを作成
|
||||
- Markdown エディタで記述(シンタックスハイライト + リアルタイムプレビュー)
|
||||
- **プリセット切り替え**: プリセットを選択 → 「Activate」で即適用
|
||||
- **同期先**:
|
||||
- Claude: `~/.claude/CLAUDE.md`
|
||||
- Codex: `~/.codex/AGENTS.md`
|
||||
- Gemini: `~/.gemini/GEMINI.md`
|
||||
- **保護機構**: 切り替え前に現在の内容を自動保存し、手動変更を保持
|
||||
|
||||
### 設定ファイルパス
|
||||
|
||||
**Claude Code**
|
||||
|
||||
- ライブ設定: `~/.claude/settings.json`(または `claude.json`)
|
||||
- API キーフィールド: `env.ANTHROPIC_AUTH_TOKEN` または `env.ANTHROPIC_API_KEY`
|
||||
- MCP サーバー: `~/.claude.json` → `mcpServers`
|
||||
|
||||
**Codex**
|
||||
|
||||
- ライブ設定: `~/.codex/auth.json`(必須)+ `config.toml`(任意)
|
||||
- API キーフィールド: `auth.json` 内の `OPENAI_API_KEY`
|
||||
- MCP サーバー: `~/.codex/config.toml` → `[mcp_servers]` テーブル
|
||||
|
||||
**Gemini**
|
||||
|
||||
- ライブ設定: `~/.gemini/.env`(API キー)+ `~/.gemini/settings.json`(認証モード)
|
||||
- API キーフィールド: `.env` 内の `GEMINI_API_KEY` または `GOOGLE_GEMINI_API_KEY`
|
||||
- 環境変数: `GOOGLE_GEMINI_BASE_URL`、`GEMINI_MODEL` などをサポート
|
||||
- MCP サーバー: `~/.gemini/settings.json` → `mcpServers`
|
||||
- トレイでのクイックスイッチ: プロバイダ切り替えごとに `~/.gemini/.env` を書き換えるため Gemini CLI の再起動は不要
|
||||
|
||||
**CC Switch 保存先 (v3.8.0 新アーキテクチャ)**
|
||||
|
||||
- データベース (SSOT): `~/.cc-switch/cc-switch.db`(SQLite。プロバイダ、MCP、Prompts、Skills を保存)
|
||||
- ローカル設定: `~/.cc-switch/settings.json`(デバイスレベル設定)
|
||||
- バックアップ: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
|
||||
|
||||
### クラウド同期の設定
|
||||
|
||||
1. 設定 → 「Custom Configuration Directory」へ進む
|
||||
2. クラウド同期フォルダ(Dropbox、OneDrive、iCloud など)を選択
|
||||
3. アプリを再起動して反映
|
||||
4. 他のデバイスでも同じフォルダを指定すればクロスデバイス同期が有効に
|
||||
|
||||
> **補足**: 初回起動時に既存の Claude/Codex 設定をデフォルトプロバイダとして自動インポートします。
|
||||
|
||||
## アーキテクチャ概要
|
||||
|
||||
### 設計原則
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React + TS) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Components │ │ Hooks │ │ TanStack Query │ │
|
||||
│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│ Tauri IPC
|
||||
┌────────────────────────▼────────────────────────────────────┐
|
||||
│ Backend (Tauri + Rust) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Commands │ │ Services │ │ Models/Config │ │
|
||||
│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**コア設計パターン**
|
||||
|
||||
- **SSOT** (Single Source of Truth): すべてのデータを `~/.cc-switch/cc-switch.db`(SQLite)に集約
|
||||
- **二層ストレージ**: 同期データは SQLite、デバイスデータは JSON
|
||||
- **双方向同期**: 切り替え時はライブファイルへ書き込み、編集時はアクティブプロバイダから逆同期
|
||||
- **アトミック書き込み**: 一時ファイル + rename パターンで設定破損を防止
|
||||
- **並行安全**: Mutex で保護された DB 接続でレースを防ぐ
|
||||
- **レイヤードアーキテクチャ**: Commands → Services → DAO → Database を明確に分離
|
||||
|
||||
**主要コンポーネント**
|
||||
|
||||
- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート
|
||||
- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期
|
||||
- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション
|
||||
- **SpeedtestService**: API エンドポイントの遅延計測
|
||||
|
||||
**v3.6 リファクタリング**
|
||||
|
||||
- バックエンド: エラーハンドリング → コマンド分割 → テスト → サービス層 → 並行性の 5 フェーズ
|
||||
- フロントエンド: テスト基盤 → hooks → コンポーネント → クリーンアップの 4 ステージ
|
||||
- テスト: hooks 100% カバレッジ + 統合テスト(vitest + MSW)
|
||||
|
||||
## 開発
|
||||
|
||||
### 開発環境
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm 8+
|
||||
- Rust 1.85+
|
||||
- Tauri CLI 2.8+
|
||||
|
||||
### 開発コマンド
|
||||
|
||||
```bash
|
||||
# 依存関係をインストール
|
||||
pnpm install
|
||||
|
||||
# ホットリロード付き開発モード
|
||||
pnpm dev
|
||||
|
||||
# 型チェック
|
||||
pnpm typecheck
|
||||
|
||||
# コード整形
|
||||
pnpm format
|
||||
|
||||
# フォーマット検証
|
||||
pnpm format:check
|
||||
|
||||
# フロントエンド単体テスト
|
||||
pnpm test:unit
|
||||
|
||||
# ウォッチモード(開発に推奨)
|
||||
pnpm test:unit:watch
|
||||
|
||||
# アプリをビルド
|
||||
pnpm build
|
||||
|
||||
# デバッグビルド
|
||||
pnpm tauri build --debug
|
||||
```
|
||||
|
||||
### Rust バックエンド開発
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
|
||||
# Rust コード整形
|
||||
cargo fmt
|
||||
|
||||
# clippy チェック
|
||||
cargo clippy
|
||||
|
||||
# バックエンドテスト
|
||||
cargo test
|
||||
|
||||
# 特定テストのみ実行
|
||||
cargo test test_name
|
||||
|
||||
# test-hooks フィーチャー付きでテスト
|
||||
cargo test --features test-hooks
|
||||
```
|
||||
|
||||
### テストガイド (v3.6)
|
||||
|
||||
**フロントエンドテスト**:
|
||||
|
||||
- テストフレームワークに **vitest** を使用
|
||||
- **MSW (Mock Service Worker)** で Tauri API 呼び出しをモック
|
||||
- コンポーネントテストに **@testing-library/react** を採用
|
||||
|
||||
**テストカバレッジ**:
|
||||
|
||||
- Hooks の単体テスト(100% カバレッジ)
|
||||
- `useProviderActions` - プロバイダ操作
|
||||
- `useMcpActions` - MCP 管理
|
||||
- `useSettings` 系 - 設定管理
|
||||
- `useImportExport` - インポート/エクスポート
|
||||
- 統合テスト
|
||||
- アプリのメインフロー
|
||||
- SettingsDialog の一連操作
|
||||
- MCP パネルの機能
|
||||
|
||||
**テスト実行**:
|
||||
|
||||
```bash
|
||||
# 全テストを実行
|
||||
pnpm test:unit
|
||||
|
||||
# ウォッチモード(自動再実行)
|
||||
pnpm test:unit:watch
|
||||
|
||||
# カバレッジレポート付き
|
||||
pnpm test:unit --coverage
|
||||
```
|
||||
|
||||
## 技術スタック
|
||||
|
||||
**フロントエンド**: React 18 · TypeScript · Vite · TailwindCSS 4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit
|
||||
|
||||
**バックエンド**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log
|
||||
|
||||
**テスト**: vitest · MSW · @testing-library/react
|
||||
|
||||
## プロジェクト構成
|
||||
|
||||
```
|
||||
├── src/ # フロントエンド (React + TypeScript)
|
||||
│ ├── components/ # UI コンポーネント (providers/settings/mcp/ui)
|
||||
│ ├── hooks/ # ビジネスロジック用カスタムフック
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Tauri API ラッパー (型安全)
|
||||
│ │ └── query/ # TanStack Query 設定
|
||||
│ ├── i18n/locales/ # 翻訳 (zh/en)
|
||||
│ ├── config/ # プリセット (providers/mcp)
|
||||
│ └── types/ # TypeScript 型定義
|
||||
├── src-tauri/ # バックエンド (Rust)
|
||||
│ └── src/
|
||||
│ ├── commands/ # Tauri コマンド層 (ドメイン別)
|
||||
│ ├── services/ # ビジネスロジック層
|
||||
│ ├── app_config.rs # 設定モデル
|
||||
│ ├── provider.rs # プロバイダドメインモデル
|
||||
│ ├── mcp.rs # MCP 同期 & 検証
|
||||
│ └── lib.rs # アプリエントリ & トレイメニュー
|
||||
├── tests/ # フロントエンドテスト
|
||||
│ ├── hooks/ # 単体テスト
|
||||
│ └── components/ # 統合テスト
|
||||
└── assets/ # スクリーンショット & スポンサーリソース
|
||||
```
|
||||
|
||||
## 更新履歴
|
||||
|
||||
詳細は [CHANGELOG.md](CHANGELOG.md) をご覧ください。
|
||||
|
||||
## 旧 Electron 版
|
||||
|
||||
[Releases](../../releases) に v2.0.3 の Electron 旧版を残しています。
|
||||
|
||||
旧版コードが必要な場合は `electron-legacy` ブランチを取得してください。
|
||||
|
||||
## 貢献
|
||||
|
||||
Issue や提案を歓迎します!
|
||||
|
||||
PR を送る前に以下をご確認ください:
|
||||
|
||||
- 型チェック: `pnpm typecheck`
|
||||
- フォーマットチェック: `pnpm format:check`
|
||||
- 単体テスト: `pnpm test:unit`
|
||||
- 💡 新機能の場合は、事前に Issue でディスカッションしていただけると助かります
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||
|
||||
## ライセンス
|
||||
|
||||
MIT © Jason Young
|
||||
@@ -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/trending/typescript)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
||||
|
||||
**从供应商切换器到 AI CLI 一体化管理平台**
|
||||
|
||||
@@ -51,9 +51,42 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 当前版本:v3.7.0 | [完整更新日志](CHANGELOG.md)
|
||||
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
|
||||
|
||||
**v3.7.0 重大更新(2025-11-19)**
|
||||
**v3.8.0 重大更新(2025-11-28)**
|
||||
|
||||
**持久化架构升级 & 全新用户界面**
|
||||
|
||||
- **SQLite + JSON 双层架构**
|
||||
- 从 JSON 文件存储迁移到 SQLite + JSON 双层结构
|
||||
- 可同步数据(供应商、MCP、Prompts、Skills)存入 SQLite
|
||||
- 设备级数据(窗口状态、本地路径)保留在 JSON
|
||||
- 为未来云同步功能奠定基础
|
||||
- Schema 版本管理支持数据库迁移
|
||||
|
||||
- **全新用户界面**
|
||||
- 完全重新设计的界面布局
|
||||
- 统一的组件样式和更流畅的动画
|
||||
- 优化的视觉层次
|
||||
- Tailwind CSS 从 v4 降级到 v3.4 以提升浏览器兼容性
|
||||
|
||||
- **日语支持**
|
||||
- 新增日语界面支持(现支持中文/英文/日语)
|
||||
|
||||
- **开机自启**
|
||||
- 在设置中一键开启/关闭
|
||||
- 使用平台原生 API(注册表/LaunchAgent/XDG autostart)
|
||||
|
||||
- **Skills 递归扫描**
|
||||
- 支持多层目录结构
|
||||
- 允许不同仓库的同名技能
|
||||
|
||||
- **关键 Bug 修复**
|
||||
- 修复更新供应商时自定义端点丢失问题
|
||||
- 修复 Gemini 配置写入问题
|
||||
- 修复 Linux WebKitGTK 渲染问题
|
||||
|
||||
**v3.7.0 亮点**
|
||||
|
||||
**六大核心功能,18,000+ 行新增代码**
|
||||
|
||||
@@ -226,10 +259,10 @@ paru -S cc-switch-bin
|
||||
- MCP 服务器:`~/.gemini/settings.json` → `mcpServers`
|
||||
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
|
||||
|
||||
**CC Switch 存储**
|
||||
**CC Switch 存储(v3.8.0 新架构)**
|
||||
|
||||
- 主配置(SSOT):`~/.cc-switch/config.json`(包含供应商、MCP、Prompts 预设等)
|
||||
- 设置:`~/.cc-switch/settings.json`
|
||||
- 数据库(SSOT):`~/.cc-switch/cc-switch.db`(SQLite,存储供应商、MCP、Prompts、Skills)
|
||||
- 本地设置:`~/.cc-switch/settings.json`(设备级设置)
|
||||
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)
|
||||
|
||||
### 云同步设置
|
||||
@@ -265,11 +298,12 @@ paru -S cc-switch-bin
|
||||
|
||||
**核心设计模式**
|
||||
|
||||
- **SSOT**(单一事实源):所有供应商配置存储在 `~/.cc-switch/config.json`
|
||||
- **SSOT**(单一事实源):所有数据存储在 `~/.cc-switch/cc-switch.db`(SQLite)
|
||||
- **双层存储**:SQLite 存储可同步数据,JSON 存储设备级设置
|
||||
- **双向同步**:切换时写入 live 文件,编辑当前供应商时从 live 回填
|
||||
- **原子写入**:临时文件 + 重命名模式防止配置损坏
|
||||
- **并发安全**:RwLock 与作用域守卫避免死锁
|
||||
- **分层架构**:清晰分离(Commands → Services → Models)
|
||||
- **并发安全**:Mutex 保护的数据库连接避免竞态条件
|
||||
- **分层架构**:清晰分离(Commands → Services → DAO → Database)
|
||||
|
||||
**核心组件**
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# CC Switch 国际化功能说明
|
||||
|
||||
## 已完成的工作
|
||||
|
||||
1. **安装依赖**:添加了 `react-i18next` 和 `i18next` 包
|
||||
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
|
||||
3. **翻译文件**:创建了英文和中文翻译文件
|
||||
4. **组件更新**:替换了主要组件中的硬编码文案
|
||||
5. **语言切换器**:添加了语言切换按钮
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── i18n/
|
||||
│ ├── index.ts # 国际化配置文件
|
||||
│ └── locales/
|
||||
│ ├── en.json # 英文翻译
|
||||
│ └── zh.json # 中文翻译
|
||||
├── components/
|
||||
│ └── LanguageSwitcher.tsx # 语言切换组件
|
||||
└── main.tsx # 导入国际化配置
|
||||
```
|
||||
|
||||
## 默认语言设置
|
||||
|
||||
- **默认语言**:英文 (en)
|
||||
- **回退语言**:英文 (en)
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 在组件中导入 `useTranslation`:
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation();
|
||||
return <div>{t('common.save')}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
2. 切换语言:
|
||||
```tsx
|
||||
const { i18n } = useTranslation();
|
||||
i18n.changeLanguage('zh'); // 切换到中文
|
||||
```
|
||||
|
||||
## 翻译键结构
|
||||
|
||||
- `common.*` - 通用文案(保存、取消、设置等)
|
||||
- `header.*` - 头部相关文案
|
||||
- `provider.*` - 供应商相关文案
|
||||
- `notifications.*` - 通知消息
|
||||
- `settings.*` - 设置页面文案
|
||||
- `apps.*` - 应用名称
|
||||
- `console.*` - 控制台日志信息
|
||||
|
||||
## 测试功能
|
||||
|
||||
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
|
||||
|
||||
## 已更新的组件
|
||||
|
||||
- ✅ App.tsx - 主应用组件
|
||||
- ✅ ConfirmDialog.tsx - 确认对话框
|
||||
- ✅ AddProviderModal.tsx - 添加供应商弹窗
|
||||
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||
- ✅ ProviderList.tsx - 供应商列表
|
||||
- ✅ LanguageSwitcher.tsx - 语言切换器
|
||||
- ✅ settings/SettingsDialog.tsx - 设置对话框
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
|
||||
2. 翻译键名应该有意义且结构化
|
||||
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言
|
||||
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,369 @@
|
||||
# CC Switch v3.8.0
|
||||
|
||||
> Persistence Architecture Upgrade, Laying the Foundation for Cloud Sync
|
||||
|
||||
**[中文版 →](release-note-v3.8.0-zh.md) | [日本語版 →](release-note-v3.8.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.8.0 is a major architectural upgrade that restructures the data persistence layer and user interface, laying the foundation for future cloud sync and local proxy features.
|
||||
|
||||
**Release Date**: 2025-11-28
|
||||
**Commits**: 51 commits since v3.7.1
|
||||
**Code Changes**: 207 files, +17,297 / -6,870 lines
|
||||
|
||||
---
|
||||
|
||||
## Major Updates
|
||||
|
||||
### Persistence Architecture Upgrade
|
||||
|
||||
Migrated from single JSON file storage to SQLite + JSON dual-layer architecture for hierarchical data management.
|
||||
|
||||
**Architecture Changes**:
|
||||
|
||||
```
|
||||
v3.7.x (Old) v3.8.0 (New)
|
||||
┌─────────────────┐ ┌─────────────────────────────────┐
|
||||
│ config.json │ │ SQLite (Syncable Data) │
|
||||
│ ┌───────────┐ │ │ ├─ providers Provider cfg │
|
||||
│ │ providers │ │ │ ├─ mcp_servers MCP servers │
|
||||
│ │ mcp │ │ ──> │ ├─ prompts Prompts │
|
||||
│ │ prompts │ │ │ ├─ skills Skills │
|
||||
│ │ settings │ │ │ └─ settings General cfg │
|
||||
│ └───────────┘ │ ├─────────────────────────────────┤
|
||||
└─────────────────┘ │ JSON (Device-level Data) │
|
||||
│ └─ settings.json Local settings│
|
||||
│ ├─ Window position │
|
||||
│ ├─ Path overrides │
|
||||
│ └─ Current provider ID │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Dual-layer Structure Design**:
|
||||
|
||||
| Layer | Storage | Data Types | Sync Strategy |
|
||||
| ---------- | ------- | ------------------------------- | --------------- |
|
||||
| Cloud Sync | SQLite | Providers, MCP, Prompts, Skills | Future syncable |
|
||||
| Device | JSON | Window state, local paths | Stays local |
|
||||
|
||||
**Technical Implementation**:
|
||||
|
||||
- **Schema Version Management** - Supports database structure upgrade migrations
|
||||
- **SQL Import/Export** - `backup.rs` supports SQL dump for cloud storage
|
||||
- **Transaction Support** - SQLite native transactions ensure data consistency
|
||||
- **Auto Migration** - Automatically migrates from `config.json` on first launch
|
||||
|
||||
**Modular Refactoring**:
|
||||
|
||||
```
|
||||
database/
|
||||
├── mod.rs Core Database struct and initialization
|
||||
├── schema.rs Table definitions, schema version migrations
|
||||
├── backup.rs SQL import/export, binary snapshot backup
|
||||
├── migration.rs JSON → SQLite data migration engine
|
||||
└── dao/ Data Access Object layer
|
||||
├── providers.rs Provider CRUD
|
||||
├── mcp.rs MCP server CRUD
|
||||
├── prompts.rs Prompts CRUD
|
||||
├── skills.rs Skills CRUD
|
||||
└── settings.rs Key-value settings storage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Brand New User Interface
|
||||
|
||||
Completely redesigned UI providing a more modern visual experience.
|
||||
|
||||
**Visual Improvements**:
|
||||
|
||||
- Redesigned interface layout
|
||||
- Unified component styles
|
||||
- Smoother transition animations
|
||||
- Optimized visual hierarchy
|
||||
|
||||
**Interaction Enhancements**:
|
||||
|
||||
- Redesigned header toolbar
|
||||
- Unified ConfirmDialog styling
|
||||
- Disabled overscroll bounce effect on main view
|
||||
- Improved form validation feedback
|
||||
|
||||
**Compatibility Adjustments**:
|
||||
|
||||
- Downgraded Tailwind CSS from v4 to v3.4 for better browser compatibility
|
||||
|
||||
---
|
||||
|
||||
### Japanese Language Support
|
||||
|
||||
Added Japanese interface support, expanding internationalization to three languages.
|
||||
|
||||
**Supported Languages**:
|
||||
|
||||
- Simplified Chinese
|
||||
- English
|
||||
- Japanese (New)
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
### Skills Recursive Scanning
|
||||
|
||||
Skills management system now supports recursive scanning of repository directories, automatically discovering nested skill files.
|
||||
|
||||
**Improvements**:
|
||||
|
||||
- Support for multi-level directory structures
|
||||
- Automatic discovery of all `SKILL.md` files
|
||||
- Allow same-named skills from different repositories (using full path for deduplication)
|
||||
|
||||
---
|
||||
|
||||
### Provider Icon Configuration
|
||||
|
||||
Provider presets now support custom icon configuration.
|
||||
|
||||
**Features**:
|
||||
|
||||
- Preset providers include default icons
|
||||
- Icon settings preserved when duplicating providers
|
||||
- Custom icon colors
|
||||
|
||||
---
|
||||
|
||||
### Enhanced Form Validation
|
||||
|
||||
Provider forms now include required field validation with friendlier error messages.
|
||||
|
||||
**Improvements**:
|
||||
|
||||
- Real-time validation for required fields
|
||||
- Unified Toast notifications for validation errors
|
||||
- Clearer error messages
|
||||
|
||||
---
|
||||
|
||||
### Auto Launch on Startup
|
||||
|
||||
Added auto-launch functionality supporting Windows, macOS, and Linux platforms.
|
||||
|
||||
**Features**:
|
||||
|
||||
- One-click enable/disable in settings
|
||||
- Implemented using platform-native APIs
|
||||
- Windows uses Registry, macOS uses LaunchAgent, Linux uses XDG autostart
|
||||
|
||||
---
|
||||
|
||||
### New Provider Presets
|
||||
|
||||
- **MiniMax** - Official partner
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Critical Fixes
|
||||
|
||||
**Custom Endpoints Lost Issue**
|
||||
|
||||
Fixed an issue where custom request URLs were unexpectedly lost when updating providers.
|
||||
|
||||
- Root Cause: `INSERT OR REPLACE` executes `DELETE + INSERT` under the hood in SQLite, triggering foreign key cascade deletion
|
||||
- Fix: Changed to use `UPDATE` statement for existing providers
|
||||
|
||||
**Gemini Configuration Issues**
|
||||
|
||||
- Fixed custom provider environment variables not correctly written to `.env` file
|
||||
- Fixed security auth config incorrectly written to other config files
|
||||
|
||||
**Provider Validation Issues**
|
||||
|
||||
- Fixed validation error when current provider ID doesn't exist
|
||||
- Fixed icon fields lost when duplicating providers
|
||||
|
||||
### Platform Compatibility
|
||||
|
||||
**Linux**
|
||||
|
||||
- Resolved WebKitGTK DMA-BUF rendering issue
|
||||
- Preserve user `.desktop` file customizations
|
||||
|
||||
### Other Fixes
|
||||
|
||||
- Fixed redundant usage queries when switching apps
|
||||
- Fixed DMXAPI preset using wrong auth token field
|
||||
- Fixed missing translation keys in deeplink components
|
||||
- Fixed usage script template initialization logic
|
||||
|
||||
---
|
||||
|
||||
## Technical Improvements
|
||||
|
||||
### Architecture Refactoring
|
||||
|
||||
**Provider Service Modularization**:
|
||||
|
||||
```
|
||||
services/provider/
|
||||
├── mod.rs Core service - add/update/delete/switch/validate
|
||||
├── live.rs Live config file operations
|
||||
├── gemini_auth.rs Gemini auth type detection
|
||||
├── endpoints.rs Custom endpoint management
|
||||
└── usage.rs Usage script execution
|
||||
```
|
||||
|
||||
**Deeplink Modularization**:
|
||||
|
||||
```
|
||||
deeplink/
|
||||
├── mod.rs Module exports
|
||||
├── parser.rs URL parsing
|
||||
├── provider.rs Provider import logic
|
||||
├── mcp.rs MCP import logic
|
||||
├── prompt.rs Prompt import
|
||||
├── skill.rs Skills import
|
||||
└── utils.rs Utility functions
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
**Cleanup**:
|
||||
|
||||
- Removed legacy JSON-era import/export dead code
|
||||
- Removed unused MCP type exports
|
||||
- Unified error handling approach
|
||||
|
||||
**Test Updates**:
|
||||
|
||||
- Migrated tests to SQLite database architecture
|
||||
- Updated component tests to match current implementation
|
||||
- Fixed MSW handlers to adapt to new API
|
||||
|
||||
---
|
||||
|
||||
## Technical Statistics
|
||||
|
||||
```
|
||||
Overall Changes:
|
||||
- Commits: 51
|
||||
- Files: 207 files changed
|
||||
- Additions: +17,297 lines
|
||||
- Deletions: -6,870 lines
|
||||
- Net: +10,427 lines
|
||||
|
||||
Commit Type Distribution:
|
||||
- fix: 25 (Bug fixes)
|
||||
- refactor: 11 (Code refactoring)
|
||||
- feat: 9 (New features)
|
||||
- test: 1 (Testing)
|
||||
- other: 5
|
||||
|
||||
Change Area Distribution:
|
||||
- Frontend source: 112 files
|
||||
- Rust backend: 63 files
|
||||
- Test files: 20 files
|
||||
- i18n files: 3 files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Upgrading from v3.7.x
|
||||
|
||||
**Auto Migration** - Executes automatically on first launch:
|
||||
|
||||
1. Detects if `config.json` exists
|
||||
2. Migrates all data to SQLite within a transaction
|
||||
3. Migrates device-level settings to `settings.json`
|
||||
4. Shows migration success notification
|
||||
|
||||
**Data Safety**:
|
||||
|
||||
- Original `config.json` file is preserved (not deleted)
|
||||
- Error dialog displayed on migration failure, `config.json` preserved
|
||||
- Supports Dry-run mode to verify migration logic
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
### System Requirements
|
||||
|
||||
- **Windows**: Windows 10+
|
||||
- **macOS**: macOS 10.15 (Catalina)+
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||
|
||||
### Download Links
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
|
||||
|
||||
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` or `-Portable.zip`
|
||||
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` or `.zip`
|
||||
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` or `.deb`
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to all contributors who made this release possible:
|
||||
|
||||
- [@YoVinchen](https://github.com/YoVinchen) - UI and database refactoring
|
||||
- [@farion1231](https://github.com/farion1231) - Bug fixes and feature enhancements
|
||||
- Community members for testing and feedback
|
||||
|
||||
### Sponsors
|
||||
|
||||
**Zhipu AI** - GLM CODING PLAN Sponsor
|
||||
[Get 10% off with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
|
||||
**PackyCode** - API Relay Service Partner
|
||||
[Use code "cc-switch" for 10% off registration](https://www.packyapi.com/register?aff=cc-switch)
|
||||
|
||||
**ShandianShuo** - Local-first AI Voice Input
|
||||
[Free download](https://shandianshuo.cn) for Mac/Windows
|
||||
|
||||
**MiniMax** - MiniMax M2 CODING PLAN Sponsor
|
||||
[Black Friday sale, plans starting at $2](https://platform.minimax.io/subscribe/coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## Feedback & Support
|
||||
|
||||
- **Issue Reports**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||
- **Documentation**: [README](../README.md)
|
||||
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
**v3.9.0 Preview** (Tentative):
|
||||
|
||||
- Local proxy feature
|
||||
|
||||
Stay tuned for more updates!
|
||||
|
||||
---
|
||||
|
||||
**Happy Coding!**
|
||||
@@ -0,0 +1,315 @@
|
||||
# CC Switch v3.8.0
|
||||
|
||||
> 永続化アーキテクチャを刷新し、クラウド同期の土台を構築
|
||||
|
||||
**[English →](release-note-v3.8.0-en.md) | [中文版 →](release-note-v3.8.0-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.8.0 はデータ永続化レイヤーと UI を大幅に作り替え、今後のクラウド同期やローカルプロキシ機能に向けた基盤を整えたメジャーアップデートです。
|
||||
|
||||
**リリース日**: 2025-11-28
|
||||
**コミット数**: v3.7.1 以降 51 commits
|
||||
**変更量**: 207 files, +17,297 / -6,870 lines
|
||||
|
||||
---
|
||||
|
||||
## 主要アップデート
|
||||
|
||||
### 永続化アーキテクチャの刷新
|
||||
|
||||
単一の JSON 保存から、階層化された SQLite + JSON の二層構造へ移行。
|
||||
|
||||
**アーキテクチャ変更**:
|
||||
|
||||
```
|
||||
v3.7.x (旧) v3.8.0 (新)
|
||||
┌─────────────────┐ ┌─────────────────────────────────┐
|
||||
│ config.json │ │ SQLite (同期対象データ) │
|
||||
│ ┌───────────┐ │ │ ├─ providers プロバイダ設定 │
|
||||
│ │ providers │ │ │ ├─ mcp_servers MCP サーバー │
|
||||
│ │ mcp │ │ ──> │ ├─ prompts プロンプト │
|
||||
│ │ prompts │ │ │ ├─ skills Skills │
|
||||
│ │ settings │ │ │ └─ settings 汎用設定 │
|
||||
│ └───────────┘ │ ├─────────────────────────────────┤
|
||||
└─────────────────┘ │ JSON (デバイス固有データ) │
|
||||
│ └─ settings.json ローカル設定 │
|
||||
│ ├─ ウィンドウ位置 │
|
||||
│ ├─ パスの上書き │
|
||||
│ └─ 現在のプロバイダ ID │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**二層構造の設計**:
|
||||
|
||||
| レイヤー | ストレージ | データ種別 | 同期戦略 |
|
||||
| -------- | ---------- | ----------------------------------- | ---------------- |
|
||||
| クラウド | SQLite | Providers, MCP, Prompts, Skills | 将来同期対象 |
|
||||
| デバイス | JSON | ウィンドウ状態、ローカルパス | ローカル保持 |
|
||||
|
||||
**実装ポイント**:
|
||||
|
||||
- **スキーマバージョン管理**: DB 構造のマイグレーションに対応
|
||||
- **SQL インポート/エクスポート**: `backup.rs` が SQL ダンプをサポート
|
||||
- **トランザクション対応**: SQLite ネイティブで整合性を確保
|
||||
- **自動マイグレーション**: 初回起動で `config.json` から自動移行
|
||||
|
||||
**モジュール分割**:
|
||||
|
||||
```
|
||||
database/
|
||||
├── mod.rs Database 構造体と初期化
|
||||
├── schema.rs テーブル定義とスキーマ移行
|
||||
├── backup.rs SQL インポート/エクスポートとスナップショット
|
||||
├── migration.rs JSON → SQLite 変換エンジン
|
||||
└── dao/ DAO レイヤー
|
||||
├── providers.rs プロバイダ CRUD
|
||||
├── mcp.rs MCP CRUD
|
||||
├── prompts.rs プロンプト CRUD
|
||||
├── skills.rs Skills CRUD
|
||||
└── settings.rs 設定 Key-Value 保存
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 新しいユーザーインターフェース
|
||||
|
||||
よりモダンな見た目と操作感に再設計。
|
||||
|
||||
- レイアウト全面刷新、コンポーネントスタイルを統一
|
||||
- トランジションを滑らかにし、視覚的階層を最適化
|
||||
- メインビューのオーバースクロールバウンスを無効化
|
||||
- ブラウザ互換性向上のため Tailwind CSS を v4→v3.4 にダウングレード
|
||||
|
||||
---
|
||||
|
||||
### 日語化
|
||||
|
||||
UI が日本語に対応し、国際化が 3 言語(中/英/日)へ拡大。
|
||||
|
||||
---
|
||||
|
||||
## 新機能
|
||||
|
||||
### Skills 递帰スキャン
|
||||
|
||||
Skills 管理がリポジトリを再帰的に走査し、ネストされた `SKILL.md` を自動検出。
|
||||
|
||||
- 複数階層のディレクトリに対応
|
||||
- すべての `SKILL.md` を自動発見
|
||||
- パスをキーにした重複排除で同名スキルを許容
|
||||
|
||||
### プロバイダアイコン設定
|
||||
|
||||
プリセットがデフォルトアイコンを持ち、複製してもアイコンを保持。カスタム色も設定可能。
|
||||
|
||||
### フォームバリデーション強化
|
||||
|
||||
必須項目にリアルタイム検証と分かりやすいエラーメッセージを追加し、トースト通知を統一。
|
||||
|
||||
### 自動起動
|
||||
|
||||
Windows/macOS/Linux で自動起動をサポート。
|
||||
|
||||
- 設定画面からワンクリックで ON/OFF
|
||||
- Registry / LaunchAgent / XDG autostart を使用
|
||||
|
||||
### 新プロバイダプリセット
|
||||
|
||||
- **MiniMax** - 公式パートナー
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### 重要修正
|
||||
|
||||
**カスタムエンドポイント消失**
|
||||
|
||||
- 原因: SQLite の `INSERT OR REPLACE` が内部で `DELETE + INSERT` を実行し、外部キーのカスケード削除が発生
|
||||
- 対応: 既存プロバイダ更新を `UPDATE` に変更
|
||||
|
||||
**Gemini 設定**
|
||||
|
||||
- カスタム環境変数が `.env` に正しく書き込まれない問題を修正
|
||||
- 認証設定が他ファイルに誤って書き込まれる問題を修正
|
||||
|
||||
**プロバイダ検証**
|
||||
|
||||
- 現在プロバイダ ID が欠落している場合のバリデーションエラーを修正
|
||||
- 複製時にアイコンフィールドが失われる問題を修正
|
||||
|
||||
### プラットフォーム互換性
|
||||
|
||||
**Linux**
|
||||
|
||||
- WebKitGTK の DMA-BUF 描画問題を解消
|
||||
- ユーザーの `.desktop` カスタマイズを保持
|
||||
|
||||
### その他修正
|
||||
|
||||
- アプリ切り替え時の不要な使用量クエリを削減
|
||||
- DMXAPI プリセットの誤ったトークンフィールドを修正
|
||||
- Deeplink コンポーネントの欠損翻訳キーを補完
|
||||
- 使用量スクリプトテンプレート初期化を修正
|
||||
|
||||
---
|
||||
|
||||
## 技術的改善
|
||||
|
||||
### アーキテクチャ再編
|
||||
|
||||
**Provider Service のモジュール化**:
|
||||
|
||||
```
|
||||
services/provider/
|
||||
├── mod.rs 追加/更新/削除/切替/検証の中核
|
||||
├── live.rs ライブ設定ファイル操作
|
||||
├── gemini_auth.rs Gemini 認証タイプ検出
|
||||
├── endpoints.rs カスタムエンドポイント管理
|
||||
└── usage.rs 使用量スクリプト実行
|
||||
```
|
||||
|
||||
**Deeplink のモジュール化**:
|
||||
|
||||
```
|
||||
deeplink/
|
||||
├── mod.rs エクスポート
|
||||
├── parser.rs URL パース
|
||||
├── provider.rs プロバイダ取り込み
|
||||
├── mcp.rs MCP 取り込み
|
||||
├── prompt.rs プロンプト取り込み
|
||||
├── skill.rs Skills 取り込み
|
||||
└── utils.rs ユーティリティ
|
||||
```
|
||||
|
||||
### コード品質
|
||||
|
||||
- レガシーな JSON 時代のインポート/エクスポート死蔵コードを削除
|
||||
- 使われていない MCP 型を削除し、エラーハンドリングを統一
|
||||
- テストを SQLite バックエンドに移行し、MSW ハンドラを最新 API に合わせて更新
|
||||
|
||||
---
|
||||
|
||||
## 技術統計
|
||||
|
||||
```
|
||||
全体変更:
|
||||
- コミット: 51
|
||||
- 変更ファイル: 207
|
||||
- 追加: +17,297 行
|
||||
- 削除: -6,870 行
|
||||
- 純増: +10,427 行
|
||||
|
||||
コミット種別:
|
||||
- fix: 25
|
||||
- refactor: 11
|
||||
- feat: 9
|
||||
- test: 1
|
||||
- other: 5
|
||||
|
||||
変更箇所:
|
||||
- フロントエンド: 112 files
|
||||
- Rust バックエンド: 63 files
|
||||
- テスト: 20 files
|
||||
- i18n: 3 files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## マイグレーションガイド
|
||||
|
||||
### v3.7.x からのアップグレード
|
||||
|
||||
**自動マイグレーション**(初回起動時):
|
||||
|
||||
1. `config.json` の存在を検出
|
||||
2. 全データをトランザクションで SQLite に移行
|
||||
3. デバイス設定を `settings.json` へ移行
|
||||
4. 移行成功の通知を表示
|
||||
|
||||
**データ保護**:
|
||||
|
||||
- 元の `config.json` は保持(削除しない)
|
||||
- 失敗時はエラーダイアログを表示し、`config.json` を温存
|
||||
- Dry-run モードで検証可能
|
||||
|
||||
---
|
||||
|
||||
## ダウンロード & インストール
|
||||
|
||||
### システム要件
|
||||
|
||||
- **Windows**: Windows 10+
|
||||
- **macOS**: macOS 10.15 (Catalina)+
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||
|
||||
### ダウンロード
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から入手:
|
||||
|
||||
- **Windows**: `CC-Switch-v3.8.0-Windows.msi` または `-Portable.zip`
|
||||
- **macOS**: `CC-Switch-v3.8.0-macOS.tar.gz` または `.zip`
|
||||
- **Linux**: `CC-Switch-v3.8.0-Linux.AppImage` または `.deb`
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
アップデート:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
### コントリビューター
|
||||
|
||||
- [@YoVinchen](https://github.com/YoVinchen) - UI とデータベースリファクタ
|
||||
- [@farion1231](https://github.com/farion1231) - バグ修正と機能拡張
|
||||
- コミュニティの皆さん - テストとフィードバック
|
||||
|
||||
### スポンサー
|
||||
|
||||
**Zhipu AI** - GLM CODING PLAN スポンサー
|
||||
[10% オフリンク](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
|
||||
**PackyCode** - API リレーサービスパートナー
|
||||
[登録時に「cc-switch」で 10% オフ](https://www.packyapi.com/register?aff=cc-switch)
|
||||
|
||||
**ShandianShuo** - ローカルファースト音声入力
|
||||
[Mac/Windows 無料ダウンロード](https://shandianshuo.cn)
|
||||
|
||||
**MiniMax** - MiniMax M2 CODING PLAN スポンサー
|
||||
[ブラックフライデーセール中、$2 から](https://platform.minimax.io/subscribe/coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## フィードバック & サポート
|
||||
|
||||
- **Issue**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||
- **ドキュメント**: [README](../README.md)
|
||||
- **更新履歴**: [CHANGELOG.md](../CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## 今後のロードマップ
|
||||
|
||||
**v3.9.0 予告(予定)**:
|
||||
|
||||
- ローカルプロキシ機能
|
||||
|
||||
続報にご期待ください!
|
||||
|
||||
---
|
||||
|
||||
**Happy Coding!**
|
||||
@@ -0,0 +1,369 @@
|
||||
# CC Switch v3.8.0
|
||||
|
||||
> 持久化架构升级,为云同步奠定基础
|
||||
|
||||
**[English Version →](release-note-v3.8.0-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.8.0 是一次重大的架构升级版本,重构了数据持久化层和用户界面,为未来的云同步和本地代理功能奠定基础。
|
||||
|
||||
**发布日期**:2025-11-28
|
||||
**提交数量**:从 v3.7.1 开始 51 个提交
|
||||
**代码变更**:207 个文件,+17,297 / -6,870 行
|
||||
|
||||
---
|
||||
|
||||
## 重大更新
|
||||
|
||||
### 持久化架构升级
|
||||
|
||||
从单一 JSON 文件存储迁移到 SQLite + JSON 双层架构,实现数据分层管理。
|
||||
|
||||
**架构变更**:
|
||||
|
||||
```
|
||||
v3.7.x (旧) v3.8.0 (新)
|
||||
┌─────────────────┐ ┌─────────────────────────────────┐
|
||||
│ config.json │ │ SQLite (可同步数据) │
|
||||
│ ┌───────────┐ │ │ ├─ providers 供应商配置 │
|
||||
│ │ providers │ │ │ ├─ mcp_servers MCP 服务器 │
|
||||
│ │ mcp │ │ ──> │ ├─ prompts 提示词 │
|
||||
│ │ prompts │ │ │ ├─ skills 技能 │
|
||||
│ │ settings │ │ │ └─ settings 通用设置 │
|
||||
│ └───────────┘ │ ├─────────────────────────────────┤
|
||||
└─────────────────┘ │ JSON (设备级数据) │
|
||||
│ └─ settings.json 本地设置 │
|
||||
│ ├─ 窗口位置 │
|
||||
│ ├─ 路径覆盖 │
|
||||
│ └─ 当前选中供应商 ID │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
**双层结构设计**:
|
||||
|
||||
| 层级 | 存储方式 | 数据类型 | 同步策略 |
|
||||
| -------- | -------- | ---------------------------- | ---------- |
|
||||
| 云同步层 | SQLite | 供应商、MCP、Prompts、Skills | 未来可同步 |
|
||||
| 设备层 | JSON | 窗口状态、本地路径、当前选择 | 保持本地 |
|
||||
|
||||
**技术实现**:
|
||||
|
||||
- **Schema 版本管理** - 支持数据库结构升级迁移
|
||||
- **SQL 导入导出** - `backup.rs` 支持 SQL dump,便于云端存储
|
||||
- **事务支持** - SQLite 原生事务保证数据一致性
|
||||
- **自动迁移** - 首次启动自动从 `config.json` 迁移数据
|
||||
|
||||
**模块化重构**:
|
||||
|
||||
```
|
||||
database/
|
||||
├── mod.rs 核心 Database 结构体和初始化
|
||||
├── schema.rs 表结构定义、Schema 版本迁移
|
||||
├── backup.rs SQL 导入导出、二进制快照备份
|
||||
├── migration.rs JSON → SQLite 数据迁移引擎
|
||||
└── dao/ 数据访问对象层
|
||||
├── providers.rs 供应商 CRUD
|
||||
├── mcp.rs MCP 服务器 CRUD
|
||||
├── prompts.rs 提示词 CRUD
|
||||
├── skills.rs Skills CRUD
|
||||
└── settings.rs 键值对设置存储
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 全新用户界面
|
||||
|
||||
完整重构的 UI 设计,提供更现代化的视觉体验。
|
||||
|
||||
**视觉改进**:
|
||||
|
||||
- 重新设计的界面布局
|
||||
- 统一的组件样式
|
||||
- 更流畅的过渡动画
|
||||
- 优化的视觉层次
|
||||
|
||||
**交互优化**:
|
||||
|
||||
- Header toolbar 重新设计
|
||||
- ConfirmDialog 样式统一
|
||||
- 禁用主视图 overscroll 弹跳效果
|
||||
- 改进的表单验证反馈
|
||||
|
||||
**兼容性调整**:
|
||||
|
||||
- Tailwind CSS 从 v4 降级到 v3.4,提升浏览器兼容性
|
||||
|
||||
---
|
||||
|
||||
### 日语支持
|
||||
|
||||
新增日语(日本語)界面支持,国际化语言扩展到三种。
|
||||
|
||||
**支持语言**:
|
||||
|
||||
- 简体中文
|
||||
- English
|
||||
- 日本語(新增)
|
||||
|
||||
---
|
||||
|
||||
## 新增功能
|
||||
|
||||
### Skills 递归扫描
|
||||
|
||||
Skills 管理系统支持递归扫描仓库目录,自动发现嵌套的技能文件。
|
||||
|
||||
**改进内容**:
|
||||
|
||||
- 支持多层目录结构
|
||||
- 自动发现所有 `SKILL.md` 文件
|
||||
- 允许不同仓库的同名技能(使用完整路径去重)
|
||||
|
||||
---
|
||||
|
||||
### 供应商图标配置
|
||||
|
||||
供应商预设支持自定义图标配置。
|
||||
|
||||
**功能特性**:
|
||||
|
||||
- 预设供应商包含默认图标
|
||||
- 复制供应商时保留图标设置
|
||||
- 图标颜色自定义
|
||||
|
||||
---
|
||||
|
||||
### 表单验证增强
|
||||
|
||||
供应商表单新增必填字段验证,提供更友好的错误提示。
|
||||
|
||||
**改进内容**:
|
||||
|
||||
- 必填字段实时校验
|
||||
- 统一使用 Toast 通知显示验证错误
|
||||
- 更清晰的错误信息
|
||||
|
||||
---
|
||||
|
||||
### 开机自启
|
||||
|
||||
新增开机自动启动功能,支持 Windows、macOS 和 Linux 三个平台。
|
||||
|
||||
**功能特性**:
|
||||
|
||||
- 在设置中一键开启/关闭
|
||||
- 使用平台原生 API 实现
|
||||
- Windows 使用注册表、macOS 使用 LaunchAgent、Linux 使用 XDG autostart
|
||||
|
||||
---
|
||||
|
||||
### 新增供应商预设
|
||||
|
||||
- **MiniMax** - 官方合作伙伴
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### 关键修复
|
||||
|
||||
**自定义端点丢失问题**
|
||||
|
||||
修复更新供应商时自定义请求地址意外丢失的问题。
|
||||
|
||||
- 根因:`INSERT OR REPLACE` 在 SQLite 底层执行 `DELETE + INSERT`,触发外键级联删除
|
||||
- 修复:改用 `UPDATE` 语句更新已存在的供应商
|
||||
|
||||
**Gemini 配置问题**
|
||||
|
||||
- 修复自定义供应商环境变量未正确写入 `.env` 文件
|
||||
- 修复安全认证配置错误写入到其他配置文件
|
||||
|
||||
**供应商验证问题**
|
||||
|
||||
- 修复当前供应商 ID 不存在时的验证错误
|
||||
- 修复供应商复制时图标字段丢失
|
||||
|
||||
### 平台兼容性
|
||||
|
||||
**Linux**
|
||||
|
||||
- 解决 WebKitGTK DMA-BUF 渲染问题
|
||||
- 保留用户 `.desktop` 文件自定义
|
||||
|
||||
### 其他修复
|
||||
|
||||
- 修复切换应用时的冗余用量查询
|
||||
- 修复 DMXAPI 预设使用错误的认证令牌字段
|
||||
- 修复深链接组件缺少翻译键
|
||||
- 修复用量脚本模板初始化逻辑
|
||||
|
||||
---
|
||||
|
||||
## 技术改进
|
||||
|
||||
### 架构重构
|
||||
|
||||
**供应商服务模块化**:
|
||||
|
||||
```
|
||||
services/provider/
|
||||
├── mod.rs 核心服务 - add/update/delete/switch/validate
|
||||
├── live.rs Live 配置文件操作
|
||||
├── gemini_auth.rs Gemini 认证类型检测
|
||||
├── endpoints.rs 自定义端点管理
|
||||
└── usage.rs 用量脚本执行
|
||||
```
|
||||
|
||||
**深链接模块化**:
|
||||
|
||||
```
|
||||
deeplink/
|
||||
├── mod.rs 模块导出
|
||||
├── parser.rs URL 解析
|
||||
├── provider.rs 供应商导入逻辑
|
||||
├── mcp.rs MCP 导入逻辑
|
||||
├── prompt.rs 提示词导入
|
||||
├── skill.rs Skills 导入
|
||||
└── utils.rs 工具函数
|
||||
```
|
||||
|
||||
### 代码质量
|
||||
|
||||
**清理工作**:
|
||||
|
||||
- 移除 JSON 时代遗留的导入导出死代码
|
||||
- 移除未使用的 MCP 类型导出
|
||||
- 统一错误处理方式
|
||||
|
||||
**测试更新**:
|
||||
|
||||
- 迁移测试到 SQLite 数据库架构
|
||||
- 更新组件测试匹配当前实现
|
||||
- 修复 MSW handlers 适配新 API
|
||||
|
||||
---
|
||||
|
||||
## 技术统计
|
||||
|
||||
```
|
||||
总体变更:
|
||||
- 提交数:51
|
||||
- 文件数:207 个文件变更
|
||||
- 新增:+17,297 行
|
||||
- 删除:-6,870 行
|
||||
- 净增:+10,427 行
|
||||
|
||||
提交类型分布:
|
||||
- fix:25 个(Bug 修复)
|
||||
- refactor:11 个(代码重构)
|
||||
- feat:9 个(新功能)
|
||||
- test:1 个(测试)
|
||||
- 其他:5 个
|
||||
|
||||
改动区域分布:
|
||||
- 前端源码:112 个文件
|
||||
- Rust 后端:63 个文件
|
||||
- 测试文件:20 个文件
|
||||
- 国际化文件:3 个文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 迁移说明
|
||||
|
||||
### 从 v3.7.x 升级
|
||||
|
||||
**自动迁移** - 首次启动时自动执行:
|
||||
|
||||
1. 检测 `config.json` 是否存在
|
||||
2. 在事务中迁移所有数据到 SQLite
|
||||
3. 设备级设置迁移到 `settings.json`
|
||||
4. 显示迁移成功通知
|
||||
|
||||
**数据安全**:
|
||||
|
||||
- 原 `config.json` 文件保留不删除
|
||||
- 迁移失败时显示错误对话框,保留`config.json`
|
||||
- 支持 Dry-run 模式验证迁移逻辑
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
### 系统要求
|
||||
|
||||
- **Windows**:Windows 10+
|
||||
- **macOS**:macOS 10.15(Catalina)+
|
||||
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+
|
||||
|
||||
### 下载链接
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
|
||||
|
||||
- **Windows**:`CC-Switch-v3.8.0-Windows.msi` 或 `-Portable.zip`
|
||||
- **macOS**:`CC-Switch-v3.8.0-macOS.tar.gz` 或 `.zip`
|
||||
- **Linux**:`CC-Switch-v3.8.0-Linux.AppImage` 或 `.deb`
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
## 致谢
|
||||
|
||||
### 贡献者
|
||||
|
||||
感谢所有让这个版本成为可能的贡献者:
|
||||
|
||||
- [@YoVinchen](https://github.com/YoVinchen) - UI 和数据库重构
|
||||
- [@farion1231](https://github.com/farion1231) - BUG 修复和功能增强
|
||||
- 社区成员的测试和反馈
|
||||
|
||||
### 赞助商
|
||||
|
||||
**智谱AI** - GLM CODING PLAN 赞助商
|
||||
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||
|
||||
**PackyCode** - API 中转服务合作伙伴
|
||||
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
|
||||
|
||||
**闪电说** - 本地优先的 AI 语音输入法
|
||||
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
|
||||
|
||||
**MiniMax** - MiniMax M2 CODING PLAN 赞助商
|
||||
[黑五优惠进行中,套餐9.9元起](https://platform.minimaxi.com/subscribe/coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## 反馈与支持
|
||||
|
||||
- **问题反馈**:[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
|
||||
- **讨论**:[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
|
||||
- **文档**:[README](../README_ZH.md)
|
||||
- **更新日志**:[CHANGELOG.md](../CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## 未来展望
|
||||
|
||||
**v3.9.0 预览**(暂定):
|
||||
|
||||
- 本地代理功能
|
||||
|
||||
敬请期待更多更新!
|
||||
|
||||
---
|
||||
|
||||
**Happy Coding!**
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.7.1",
|
||||
"version": "3.8.2",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
@@ -26,16 +26,16 @@
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"msw": "^2.11.6",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17"
|
||||
"vitest": "^2.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -75,6 +75,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"recharts": "^3.5.1",
|
||||
"smol-toml": "^1.4.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
@@ -119,6 +119,9 @@ importers:
|
||||
react-i18next:
|
||||
specifier: ^16.0.0
|
||||
version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
|
||||
recharts:
|
||||
specifier: ^3.5.1
|
||||
version: 3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1)
|
||||
smol-toml:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2
|
||||
@@ -1047,6 +1050,17 @@ packages:
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@reduxjs/toolkit@2.11.0':
|
||||
resolution: {integrity: sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
@@ -1161,6 +1175,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
@@ -1307,6 +1324,33 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.7':
|
||||
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -1327,6 +1371,9 @@ packages:
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@@ -1526,6 +1573,50 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.0:
|
||||
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1539,6 +1630,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -1609,6 +1703,9 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.42.0:
|
||||
resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1621,6 +1718,9 @@ packages:
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
expect-type@1.2.2:
|
||||
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1738,10 +1838,20 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.0.1:
|
||||
resolution: {integrity: sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==}
|
||||
|
||||
indent-string@4.0.0:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2115,6 +2225,18 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.17.0:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2160,14 +2282,33 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
recharts@3.5.1:
|
||||
resolution: {integrity: sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve@1.22.11:
|
||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2284,6 +2425,9 @@ packages:
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -2392,9 +2536,17 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -3416,6 +3568,18 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.0.1
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.46.2':
|
||||
@@ -3478,6 +3642,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.46.2':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@tanstack/query-core@5.90.3': {}
|
||||
@@ -3609,6 +3775,30 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.2
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.7':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/node@20.19.9':
|
||||
@@ -3628,6 +3818,8 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
@@ -3841,6 +4033,44 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.0: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
@@ -3850,6 +4080,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
@@ -3902,6 +4134,8 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
es-toolkit@1.42.0: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
@@ -3934,6 +4168,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
expect-type@1.2.2: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@@ -4051,8 +4287,14 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.0.1: {}
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
@@ -4352,6 +4594,15 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
redux: 5.0.1
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1):
|
||||
@@ -4393,13 +4644,41 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
recharts@3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.42.0
|
||||
eventemitter3: 5.0.1
|
||||
immer: 10.2.0
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
is-core-module: 2.16.1
|
||||
@@ -4545,6 +4824,8 @@ snapshots:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@@ -4629,8 +4910,29 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.23
|
||||
|
||||
use-sync-external-store@1.6.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.7
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@2.1.9(@types/node@20.19.9)(lightningcss@1.30.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
||||
@@ -257,6 +257,28 @@ dependencies = [
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
@@ -320,6 +342,61 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.76"
|
||||
@@ -618,14 +695,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.7.1"
|
||||
version = "3.8.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"auto-launch",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"hyper",
|
||||
"indexmap 2.11.4",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -635,6 +716,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"rquickjs",
|
||||
"rusqlite",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -650,11 +732,14 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"toml_edit 0.22.27",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"url",
|
||||
"uuid",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -783,6 +868,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -806,9 +901,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -819,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1204,6 +1299,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.0"
|
||||
@@ -1369,6 +1473,15 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1376,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1390,6 +1503,12 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1833,6 +1952,25 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.11.4",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1957,6 +2095,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.7.0"
|
||||
@@ -1967,9 +2111,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1995,6 +2141,22 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.17"
|
||||
@@ -2014,9 +2176,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2541,6 +2705,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
@@ -2610,6 +2780,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3027,6 +3214,50 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -3770,16 +4001,21 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -3790,10 +4026,11 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower 0.5.2",
|
||||
"tower-http 0.6.6",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -4037,6 +4274,15 @@ dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -4112,6 +4358,29 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -4206,6 +4475,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -4440,7 +4720,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -4581,6 +4861,27 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4602,7 +4903,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"block2 0.6.1",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -5241,6 +5542,16 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5378,6 +5689,17 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
@@ -5391,6 +5713,23 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5406,7 +5745,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
@@ -5429,6 +5768,7 @@ version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.7.1"
|
||||
version = "3.8.2"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -37,12 +37,18 @@ tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
bytes = "1.5"
|
||||
axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
regex = "1.10"
|
||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
zip = "2.2"
|
||||
serde_yaml = "0.9"
|
||||
@@ -53,6 +59,8 @@ once_cell = "1.21.3"
|
||||
base64 = "0.22"
|
||||
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::AppError;
|
||||
use auto_launch::AutoLaunch;
|
||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||
|
||||
/// 初始化 AutoLaunch 实例
|
||||
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||
@@ -7,13 +7,15 @@ fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||
let app_path =
|
||||
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
|
||||
|
||||
// Windows 平台的 AutoLaunch::new 只接受 3 个参数
|
||||
// Linux/macOS 平台需要 4 个参数(包含 hidden 参数)
|
||||
#[cfg(target_os = "windows")]
|
||||
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), &[] as &[&str]);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
|
||||
// 使用 AutoLaunchBuilder 消除平台差异
|
||||
// Windows/Linux: new() 接受 3 参数
|
||||
// macOS: new() 接受 4 参数(含 hidden 参数)
|
||||
// Builder 模式自动处理这些差异
|
||||
let auto_launch = AutoLaunchBuilder::new()
|
||||
.set_app_name(app_name)
|
||||
.set_app_path(&app_path.to_string_lossy())
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建 AutoLaunch 失败: {e}")))?;
|
||||
|
||||
Ok(auto_launch)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ mod env;
|
||||
mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod model_test;
|
||||
mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod proxy;
|
||||
mod settings;
|
||||
pub mod skill;
|
||||
mod usage;
|
||||
|
||||
pub use config::*;
|
||||
pub use deeplink::*;
|
||||
@@ -18,8 +21,11 @@ pub use env::*;
|
||||
pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use model_test::*;
|
||||
pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use proxy::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 模型测试相关命令
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::services::model_test::{
|
||||
ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use tauri::State;
|
||||
|
||||
/// 测试单个供应商的模型可用性
|
||||
#[tauri::command]
|
||||
pub async fn test_provider_model(
|
||||
state: State<'_, AppState>,
|
||||
app_type: AppType,
|
||||
provider_id: String,
|
||||
) -> Result<ModelTestResult, AppError> {
|
||||
// 获取测试配置
|
||||
let config = state.db.get_model_test_config()?;
|
||||
|
||||
// 获取供应商
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers
|
||||
.get(&provider_id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
// 执行测试
|
||||
let result = ModelTestService::test_provider(&app_type, provider, &config).await?;
|
||||
|
||||
// 记录日志
|
||||
let _ = state.db.save_model_test_log(
|
||||
&provider_id,
|
||||
&provider.name,
|
||||
app_type.as_str(),
|
||||
&result.model_used,
|
||||
&config.test_prompt,
|
||||
&result,
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 批量测试所有供应商
|
||||
#[tauri::command]
|
||||
pub async fn test_all_providers_model(
|
||||
state: State<'_, AppState>,
|
||||
app_type: AppType,
|
||||
proxy_targets_only: bool,
|
||||
) -> Result<Vec<(String, ModelTestResult)>, AppError> {
|
||||
let config = state.db.get_model_test_config()?;
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (id, provider) in providers {
|
||||
// 如果只测试代理目标,跳过非代理目标
|
||||
if proxy_targets_only && !provider.is_proxy_target.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match ModelTestService::test_provider(&app_type, &provider, &config).await {
|
||||
Ok(result) => {
|
||||
// 记录日志
|
||||
let _ = state.db.save_model_test_log(
|
||||
&id,
|
||||
&provider.name,
|
||||
app_type.as_str(),
|
||||
&result.model_used,
|
||||
&config.test_prompt,
|
||||
&result,
|
||||
);
|
||||
results.push((id, result));
|
||||
}
|
||||
Err(e) => {
|
||||
let error_result = ModelTestResult {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
results.push((id, error_result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取模型测试配置
|
||||
#[tauri::command]
|
||||
pub fn get_model_test_config(state: State<'_, AppState>) -> Result<ModelTestConfig, AppError> {
|
||||
state.db.get_model_test_config()
|
||||
}
|
||||
|
||||
/// 保存模型测试配置
|
||||
#[tauri::command]
|
||||
pub fn save_model_test_config(
|
||||
state: State<'_, AppState>,
|
||||
config: ModelTestConfig,
|
||||
) -> Result<(), AppError> {
|
||||
state.db.save_model_test_config(&config)
|
||||
}
|
||||
|
||||
/// 获取模型测试日志
|
||||
#[tauri::command]
|
||||
pub fn get_model_test_logs(
|
||||
state: State<'_, AppState>,
|
||||
app_type: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<ModelTestLog>, AppError> {
|
||||
state.db.get_model_test_logs(
|
||||
app_type.as_deref(),
|
||||
provider_id.as_deref(),
|
||||
limit.unwrap_or(50),
|
||||
)
|
||||
}
|
||||
|
||||
/// 清理旧的测试日志
|
||||
#[tauri::command]
|
||||
pub fn cleanup_model_test_logs(
|
||||
state: State<'_, AppState>,
|
||||
keep_count: Option<u32>,
|
||||
) -> Result<u64, AppError> {
|
||||
state.db.cleanup_model_test_logs(keep_count.unwrap_or(100))
|
||||
}
|
||||
@@ -86,7 +86,20 @@ pub fn switch_provider(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||
/// 设置代理目标供应商
|
||||
#[tauri::command]
|
||||
pub fn set_proxy_target_provider(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::set_proxy_target(state.inner(), app_type, &id)
|
||||
.map(|_| true)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
ProviderService::import_default_config(state, app_type)
|
||||
}
|
||||
|
||||
@@ -94,7 +107,7 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
|
||||
pub fn import_default_config_test_hook(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<(), AppError> {
|
||||
) -> Result<bool, AppError> {
|
||||
import_default_config_internal(state, app_type)
|
||||
}
|
||||
|
||||
@@ -102,9 +115,7 @@ pub fn import_default_config_test_hook(
|
||||
#[tauri::command]
|
||||
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
import_default_config_internal(&state, app_type)
|
||||
.map(|_| true)
|
||||
.map_err(Into::into)
|
||||
import_default_config_internal(&state, app_type).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// 查询供应商用量
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! 代理服务相关的 Tauri 命令
|
||||
//!
|
||||
//! 提供前端调用的 API 接口
|
||||
|
||||
use crate::proxy::types::*;
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 启动代理服务器
|
||||
#[tauri::command]
|
||||
pub async fn start_proxy_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ProxyServerInfo, String> {
|
||||
state.proxy_service.start().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
state.proxy_service.stop().await
|
||||
}
|
||||
|
||||
/// 获取代理服务器状态
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
|
||||
state.proxy_service.get_status().await
|
||||
}
|
||||
|
||||
/// 获取代理配置
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
|
||||
state.proxy_service.get_config().await
|
||||
}
|
||||
|
||||
/// 更新代理配置
|
||||
#[tauri::command]
|
||||
pub async fn update_proxy_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: ProxyConfig,
|
||||
) -> Result<(), String> {
|
||||
state.proxy_service.update_config(&config).await
|
||||
}
|
||||
|
||||
/// 检查代理服务器是否正在运行
|
||||
#[tauri::command]
|
||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||
Ok(state.proxy_service.is_running().await)
|
||||
}
|
||||
@@ -90,7 +90,6 @@ pub async fn install_skill(
|
||||
.clone()
|
||||
.unwrap_or_else(|| "main".to_string()),
|
||||
enabled: true,
|
||||
skills_path: skill.skills_path.clone(), // 使用技能记录的 skills_path
|
||||
};
|
||||
|
||||
service
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 使用统计相关命令
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::*;
|
||||
use crate::store::AppState;
|
||||
use tauri::State;
|
||||
|
||||
/// 获取使用量汇总
|
||||
#[tauri::command]
|
||||
pub fn get_usage_summary(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
state.db.get_usage_summary(start_date, end_date)
|
||||
}
|
||||
|
||||
/// 获取每日趋势
|
||||
#[tauri::command]
|
||||
pub fn get_usage_trends(
|
||||
state: State<'_, AppState>,
|
||||
days: u32,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
state.db.get_daily_trends(days)
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
#[tauri::command]
|
||||
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats()
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
#[tauri::command]
|
||||
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats()
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
#[tauri::command]
|
||||
pub fn get_request_logs(
|
||||
state: State<'_, AppState>,
|
||||
filters: LogFilters,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
) -> Result<PaginatedLogs, AppError> {
|
||||
state.db.get_request_logs(&filters, page, page_size)
|
||||
}
|
||||
|
||||
/// 获取单个请求详情
|
||||
#[tauri::command]
|
||||
pub fn get_request_detail(
|
||||
state: State<'_, AppState>,
|
||||
request_id: String,
|
||||
) -> Result<Option<RequestLogDetail>, AppError> {
|
||||
state.db.get_request_detail(&request_id)
|
||||
}
|
||||
|
||||
/// 获取模型定价列表
|
||||
#[tauri::command]
|
||||
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
||||
log::info!("获取模型定价列表");
|
||||
state.db.ensure_model_pricing_seeded()?;
|
||||
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
|
||||
// 检查表是否存在
|
||||
let table_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0).map(|count| count > 0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !table_exists {
|
||||
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
ORDER BY display_name",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(ModelPricingInfo {
|
||||
model_id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
input_cost_per_million: row.get(2)?,
|
||||
output_cost_per_million: row.get(3)?,
|
||||
cache_read_cost_per_million: row.get(4)?,
|
||||
cache_creation_cost_per_million: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut pricing = Vec::new();
|
||||
for row in rows {
|
||||
pricing.push(row?);
|
||||
}
|
||||
|
||||
log::info!("成功获取 {} 条模型定价数据", pricing.len());
|
||||
Ok(pricing)
|
||||
}
|
||||
|
||||
/// 更新模型定价
|
||||
#[tauri::command]
|
||||
pub fn update_model_pricing(
|
||||
state: State<'_, AppState>,
|
||||
model_id: String,
|
||||
display_name: String,
|
||||
input_cost: String,
|
||||
output_cost: String,
|
||||
cache_read_cost: String,
|
||||
cache_creation_cost: String,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
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,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查 Provider 使用限额
|
||||
#[tauri::command]
|
||||
pub fn check_provider_limits(
|
||||
state: State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
|
||||
state.db.check_provider_limits(&provider_id, &app_type)
|
||||
}
|
||||
|
||||
/// 删除模型定价
|
||||
#[tauri::command]
|
||||
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
||||
|
||||
log::info!("已删除模型定价: {model_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 模型定价信息
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelPricingInfo {
|
||||
pub model_id: String,
|
||||
pub display_name: String,
|
||||
pub input_cost_per_million: String,
|
||||
pub output_cost_per_million: String,
|
||||
pub cache_read_cost_per_million: String,
|
||||
pub cache_creation_cost_per_million: String,
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! 数据访问对象 (DAO) 模块
|
||||
//! Data Access Object layer
|
||||
//!
|
||||
//! 提供各类数据的 CRUD 操作。
|
||||
//! Database access operations for each domain
|
||||
|
||||
mod mcp;
|
||||
mod prompts;
|
||||
mod providers;
|
||||
mod settings;
|
||||
mod skills;
|
||||
pub mod mcp;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
|
||||
@@ -17,7 +17,7 @@ impl Database {
|
||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target
|
||||
FROM providers WHERE app_type = ?1
|
||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -35,6 +35,7 @@ impl Database {
|
||||
let icon: Option<String> = row.get(8)?;
|
||||
let icon_color: Option<String> = row.get(9)?;
|
||||
let meta_str: String = row.get(10)?;
|
||||
let is_proxy_target: bool = row.get(11)?;
|
||||
|
||||
let settings_config =
|
||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
@@ -54,6 +55,7 @@ impl Database {
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
is_proxy_target: Some(is_proxy_target),
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -121,6 +123,77 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 ID 获取单个供应商
|
||||
pub fn get_provider_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<Option<Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target
|
||||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
|row| {
|
||||
let name: String = row.get(0)?;
|
||||
let settings_config_str: String = row.get(1)?;
|
||||
let website_url: Option<String> = row.get(2)?;
|
||||
let category: Option<String> = row.get(3)?;
|
||||
let created_at: Option<i64> = row.get(4)?;
|
||||
let sort_index: Option<usize> = row.get(5)?;
|
||||
let notes: Option<String> = row.get(6)?;
|
||||
let icon: Option<String> = row.get(7)?;
|
||||
let icon_color: Option<String> = row.get(8)?;
|
||||
let meta_str: String = row.get(9)?;
|
||||
let is_proxy_target: bool = row.get(10)?;
|
||||
|
||||
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||
|
||||
Ok(Provider {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
settings_config,
|
||||
website_url,
|
||||
category,
|
||||
created_at,
|
||||
sort_index,
|
||||
notes,
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
is_proxy_target: Some(is_proxy_target),
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(provider) => Ok(Some(provider)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取代理目标供应商 ID
|
||||
pub fn get_proxy_target_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_proxy_target = 1 LIMIT 1")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut rows = stmt
|
||||
.query(params![app_type])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||
Ok(Some(
|
||||
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存供应商(新增或更新)
|
||||
///
|
||||
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
||||
@@ -135,17 +208,17 @@ impl Database {
|
||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
||||
let existing: Option<bool> = tx
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 is_proxy_target)
|
||||
let existing: Option<(bool, bool)> = tx
|
||||
.query_row(
|
||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
"SELECT is_current, is_proxy_target FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![provider.id, app_type],
|
||||
|row| row.get(0),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let is_update = existing.is_some();
|
||||
let is_current = existing.unwrap_or(false);
|
||||
let (is_current, is_proxy_target) = existing.unwrap_or((false, false));
|
||||
|
||||
if is_update {
|
||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||
@@ -161,8 +234,9 @@ impl Database {
|
||||
icon = ?8,
|
||||
icon_color = ?9,
|
||||
meta = ?10,
|
||||
is_current = ?11
|
||||
WHERE id = ?12 AND app_type = ?13",
|
||||
is_current = ?11,
|
||||
is_proxy_target = ?12
|
||||
WHERE id = ?13 AND app_type = ?14",
|
||||
params![
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
@@ -175,6 +249,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
is_proxy_target,
|
||||
provider.id,
|
||||
app_type,
|
||||
],
|
||||
@@ -185,8 +260,8 @@ impl Database {
|
||||
tx.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current, is_proxy_target
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
provider.id,
|
||||
app_type,
|
||||
@@ -201,6 +276,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
is_proxy_target,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -256,6 +332,47 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置代理目标供应商
|
||||
pub fn set_proxy_target_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 重置所有为 0
|
||||
tx.execute(
|
||||
"UPDATE providers SET is_proxy_target = 0 WHERE app_type = ?1",
|
||||
params![app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 设置新的代理目标供应商
|
||||
tx.execute(
|
||||
"UPDATE providers SET is_proxy_target = 1 WHERE id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有活跃的代理目标
|
||||
pub fn get_all_proxy_targets(&self) -> Result<Vec<(String, String, String)>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT app_type, name, id FROM providers WHERE is_proxy_target = 1")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let targets = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.map_err(|e| AppError::Database(e.to_string()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// 添加自定义端点
|
||||
pub fn add_custom_endpoint(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! 代理功能数据访问层
|
||||
//!
|
||||
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
|
||||
use super::super::{lock_conn, Database};
|
||||
|
||||
impl Database {
|
||||
// ==================== Proxy Config ====================
|
||||
|
||||
/// 获取代理配置
|
||||
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
||||
// 在一个作用域内获取锁并查询,确保锁在await之前释放
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT enabled, listen_address, listen_port, max_retries,
|
||||
request_timeout, enable_logging
|
||||
FROM proxy_config WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ProxyConfig {
|
||||
enabled: row.get::<_, i32>(0)? != 0,
|
||||
listen_address: row.get(1)?,
|
||||
listen_port: row.get::<_, i32>(2)? as u16,
|
||||
max_retries: row.get::<_, i32>(3)? as u8,
|
||||
request_timeout: row.get::<_, i32>(4)? as u64,
|
||||
enable_logging: row.get::<_, i32>(5)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
}; // conn锁在这里释放
|
||||
|
||||
match result {
|
||||
Ok(config) => Ok(config),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
// 如果不存在,插入默认配置
|
||||
let default_config = ProxyConfig::default();
|
||||
self.update_proxy_config(default_config.clone()).await?;
|
||||
Ok(default_config)
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新代理配置
|
||||
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_config
|
||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7,
|
||||
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
||||
datetime('now'))",
|
||||
rusqlite::params![
|
||||
if config.enabled { 1 } else { 0 },
|
||||
config.listen_address,
|
||||
config.listen_port as i32,
|
||||
config.max_retries as i32,
|
||||
config.request_timeout as i32,
|
||||
if config.enable_logging { 1 } else { 0 },
|
||||
"claude", // 兼容旧字段,写入默认值
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Provider Health ====================
|
||||
|
||||
/// 获取Provider健康状态
|
||||
pub async fn get_provider_health(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<ProviderHealth, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.query_row(
|
||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at
|
||||
FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| {
|
||||
Ok(ProviderHealth {
|
||||
provider_id: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||
last_success_at: row.get(4)?,
|
||||
last_failure_at: row.get(5)?,
|
||||
last_error: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))
|
||||
}
|
||||
|
||||
/// 更新Provider健康状态
|
||||
pub async fn update_provider_health(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
success: bool,
|
||||
error_msg: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// 先查询当前状态
|
||||
let current = conn.query_row(
|
||||
"SELECT consecutive_failures FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| Ok(row.get::<_, i64>(0)? as u32),
|
||||
);
|
||||
|
||||
let (is_healthy, consecutive_failures) = if success {
|
||||
// 成功:重置失败计数
|
||||
(1, 0)
|
||||
} else {
|
||||
// 失败:增加失败计数
|
||||
let failures = current.unwrap_or(0) + 1;
|
||||
let healthy = if failures >= 3 { 0 } else { 1 };
|
||||
(healthy, failures)
|
||||
};
|
||||
|
||||
let (last_success_at, last_failure_at) = if success {
|
||||
(Some(now.clone()), None)
|
||||
} else {
|
||||
(None, Some(now.clone()))
|
||||
};
|
||||
|
||||
// UPSERT
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO provider_health
|
||||
(provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4,
|
||||
COALESCE(?5, (SELECT last_success_at FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||
COALESCE(?6, (SELECT last_failure_at FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||
?7, ?8)",
|
||||
rusqlite::params![
|
||||
provider_id,
|
||||
app_type,
|
||||
is_healthy,
|
||||
consecutive_failures as i64,
|
||||
last_success_at,
|
||||
last_failure_at,
|
||||
error_msg,
|
||||
&now,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Proxy Usage (可选) ====================
|
||||
|
||||
/// 记录代理使用统计
|
||||
#[allow(dead_code)]
|
||||
pub async fn record_proxy_usage(&self, record: &ProxyUsageRecord) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_usage
|
||||
(provider_id, app_type, endpoint, request_tokens, response_tokens,
|
||||
status_code, latency_ms, error, timestamp)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
rusqlite::params![
|
||||
&record.provider_id,
|
||||
&record.app_type,
|
||||
&record.endpoint,
|
||||
record.request_tokens,
|
||||
record.response_tokens,
|
||||
record.status_code as i64,
|
||||
record.latency_ms as i64,
|
||||
&record.error,
|
||||
&record.timestamp,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查询最近的使用统计
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_recent_usage(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ProxyUsageRecord>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT provider_id, app_type, endpoint, request_tokens, response_tokens,
|
||||
status_code, latency_ms, error, timestamp
|
||||
FROM proxy_usage
|
||||
WHERE provider_id = ?1 AND app_type = ?2
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?3",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params![provider_id, app_type, limit as i64],
|
||||
|row| {
|
||||
Ok(ProxyUsageRecord {
|
||||
provider_id: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
endpoint: row.get(2)?,
|
||||
request_tokens: row.get(3)?,
|
||||
response_tokens: row.get(4)?,
|
||||
status_code: row.get::<_, i64>(5)? as u16,
|
||||
latency_ms: row.get::<_, i64>(6)? as u64,
|
||||
error: row.get(7)?,
|
||||
timestamp: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut records = Vec::new();
|
||||
for row in rows {
|
||||
records.push(row.map_err(|e| AppError::Database(e.to_string()))?);
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,9 @@ impl Database {
|
||||
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
|
||||
.prepare(
|
||||
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner ASC, name ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let repo_iter = stmt
|
||||
@@ -68,7 +70,6 @@ impl Database {
|
||||
name: row.get(1)?,
|
||||
branch: row.get(2)?,
|
||||
enabled: row.get(3)?,
|
||||
skills_path: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -84,8 +85,8 @@ impl Database {
|
||||
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
|
||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -153,12 +153,14 @@ impl Database {
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let migrate_app_prompts =
|
||||
|prompts_map: &std::collections::HashMap<String, crate::prompt::Prompt>,
|
||||
app_type: &str|
|
||||
-> Result<(), AppError> {
|
||||
for (id, prompt) in prompts_map {
|
||||
tx.execute(
|
||||
let migrate_app_prompts = |prompts_map: &std::collections::HashMap<
|
||||
String,
|
||||
crate::prompt::Prompt,
|
||||
>,
|
||||
app_type: &str|
|
||||
-> Result<(), AppError> {
|
||||
for (id, prompt) in prompts_map {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO prompts (
|
||||
id, app_type, name, content, description, enabled, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
@@ -174,9 +176,9 @@ impl Database {
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
migrate_app_prompts(&config.prompts.claude.prompts, "claude")?;
|
||||
migrate_app_prompts(&config.prompts.codex.prompts, "codex")?;
|
||||
@@ -200,8 +202,8 @@ impl Database {
|
||||
|
||||
for repo in &config.skills.repos {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
|
||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![repo.owner, repo.name, repo.branch, repo.enabled],
|
||||
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const DB_BACKUP_RETAIN: usize = 10;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 1;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 2;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -95,6 +95,7 @@ impl Database {
|
||||
};
|
||||
db.create_tables()?;
|
||||
db.apply_schema_migrations()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
@@ -111,32 +112,26 @@ impl Database {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.create_tables()?;
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// 检查数据库是否为空(需要首次导入)
|
||||
///
|
||||
/// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断
|
||||
pub fn is_empty_for_first_import(&self) -> Result<bool, AppError> {
|
||||
/// 检查 MCP 服务器表是否为空
|
||||
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let mcp_count: i64 = conn
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(count == 0)
|
||||
}
|
||||
|
||||
let prompt_count: i64 = conn
|
||||
/// 检查提示词表是否为空
|
||||
pub fn is_prompts_table_empty(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let skill_repo_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let provider_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0)
|
||||
Ok(count == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,19 @@ impl Database {
|
||||
icon_color TEXT,
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
is_proxy_target BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id, app_type)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 is_proxy_target 列(如果表已存在但缺少该列)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE providers ADD COLUMN is_proxy_target BOOLEAN NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// 2. Provider Endpoints 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS provider_endpoints (
|
||||
@@ -104,7 +111,6 @@ impl Database {
|
||||
name TEXT NOT NULL,
|
||||
branch TEXT NOT NULL DEFAULT 'main',
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
skills_path TEXT,
|
||||
PRIMARY KEY (owner, name)
|
||||
)",
|
||||
[],
|
||||
@@ -121,6 +127,215 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 8. Proxy Config 表 (代理服务器配置)
|
||||
// 代理配置表(单例)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS proxy_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
listen_port INTEGER NOT NULL DEFAULT 5000,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
request_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
target_app TEXT NOT NULL DEFAULT 'claude',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 target_app 列(如果表已存在但缺少该列)
|
||||
// 忽略 "duplicate column name" 错误
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
|
||||
[],
|
||||
);
|
||||
|
||||
// 9. Provider Health 表 (Provider健康状态)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS provider_health (
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
is_healthy INTEGER NOT NULL DEFAULT 1,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_success_at TEXT,
|
||||
last_failure_at TEXT,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider_id, app_type),
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 10. Proxy Usage 表 (代理使用统计,可选)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS proxy_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
request_tokens INTEGER,
|
||||
response_tokens INTEGER,
|
||||
status_code INTEGER NOT NULL,
|
||||
latency_ms INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 为 proxy_usage 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_timestamp
|
||||
ON proxy_usage(timestamp)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_provider
|
||||
ON proxy_usage(provider_id, app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 11. 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,
|
||||
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',
|
||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
latency_ms INTEGER NOT NULL,
|
||||
first_token_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
status_code INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
session_id TEXT,
|
||||
provider_type TEXT,
|
||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
||||
created_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
|
||||
ON proxy_request_logs(provider_id, app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
|
||||
ON proxy_request_logs(created_at)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
|
||||
ON proxy_request_logs(model)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
|
||||
ON proxy_request_logs(session_id)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
|
||||
ON proxy_request_logs(status_code)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 12. Model Pricing 表 (模型定价)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL,
|
||||
output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 13. Usage Daily Stats 表 (每日聚合统计)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
|
||||
date TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, provider_id, app_type, model)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 14. Model Test Logs 表 (模型测试日志,独立于代理使用统计)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_test_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
response_time_ms INTEGER,
|
||||
http_status INTEGER,
|
||||
tested_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_provider
|
||||
ON model_test_logs(provider_id, app_type)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_tested_at
|
||||
ON model_test_logs(tested_at DESC)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -151,7 +366,12 @@ impl Database {
|
||||
0 => {
|
||||
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
|
||||
Self::migrate_v0_to_v1(conn)?;
|
||||
Self::set_user_version(conn, SCHEMA_VERSION)?;
|
||||
Self::set_user_version(conn, 1)?;
|
||||
}
|
||||
1 => {
|
||||
log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
|
||||
Self::migrate_v1_to_v2(conn)?;
|
||||
Self::set_user_version(conn, 2)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
@@ -226,18 +446,262 @@ impl Database {
|
||||
Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
|
||||
|
||||
// skill_repos 表
|
||||
Self::add_column_if_missing(conn, "skill_repos", "branch", "TEXT NOT NULL DEFAULT 'main'")?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"skill_repos",
|
||||
"enabled",
|
||||
"BOOLEAN NOT NULL DEFAULT 1",
|
||||
"branch",
|
||||
"TEXT NOT NULL DEFAULT 'main'",
|
||||
)?;
|
||||
Self::add_column_if_missing(conn, "skill_repos", "skills_path", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
|
||||
// 注意: skills_path 字段已被移除,因为现在支持全仓库递归扫描
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v1 -> v2 迁移:添加使用统计表和完整字段
|
||||
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
||||
// providers 表字段
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"providers",
|
||||
"cost_multiplier",
|
||||
"TEXT NOT NULL DEFAULT '1.0'",
|
||||
)?;
|
||||
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
||||
|
||||
// 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,
|
||||
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',
|
||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
latency_ms INTEGER NOT NULL,
|
||||
first_token_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
status_code INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
session_id TEXT,
|
||||
provider_type TEXT,
|
||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
||||
created_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 为已存在的表添加新字段
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_request_logs",
|
||||
"is_streaming",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_request_logs",
|
||||
"cost_multiplier",
|
||||
"TEXT NOT NULL DEFAULT '1.0'",
|
||||
)?;
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
|
||||
|
||||
// model_pricing 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||
model_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
input_cost_per_million TEXT NOT NULL,
|
||||
output_cost_per_million TEXT NOT NULL,
|
||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// usage_daily_stats 表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
|
||||
date TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
error_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, provider_id, app_type, model)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 清空并重新插入模型定价
|
||||
conn.execute("DELETE FROM model_pricing", [])
|
||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||
Self::seed_model_pricing(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_data = [
|
||||
// Claude 4.5 系列
|
||||
(
|
||||
"claude-opus-4-5",
|
||||
"Claude Opus 4.5",
|
||||
"5",
|
||||
"25",
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-5",
|
||||
"Claude Sonnet 4.5",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
(
|
||||
"claude-haiku-4-5",
|
||||
"Claude Haiku 4.5",
|
||||
"1",
|
||||
"5",
|
||||
"0.10",
|
||||
"1.25",
|
||||
),
|
||||
// Claude 4.1 系列
|
||||
(
|
||||
"claude-opus-4-1",
|
||||
"Claude Opus 4.1",
|
||||
"15",
|
||||
"75",
|
||||
"1.50",
|
||||
"18.75",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-1",
|
||||
"Claude Sonnet 4.1",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// Claude 3.7 系列
|
||||
(
|
||||
"claude-sonnet-3-7",
|
||||
"Claude Sonnet 3.7",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// Claude 3.5 系列
|
||||
(
|
||||
"claude-sonnet-3-5",
|
||||
"Claude Sonnet 3.5",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
(
|
||||
"claude-haiku-3-5",
|
||||
"Claude Haiku 3.5",
|
||||
"0.80",
|
||||
"4",
|
||||
"0.08",
|
||||
"1",
|
||||
),
|
||||
// GPT-5 系列(model_id 使用短横线格式)
|
||||
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
||||
// Gemini 3 系列
|
||||
(
|
||||
"gemini-3-pro-preview",
|
||||
"Gemini 3 Pro Preview",
|
||||
"2",
|
||||
"12",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// Gemini 2.5 系列(model_id 使用短横线格式)
|
||||
(
|
||||
"gemini-2-5-pro",
|
||||
"Gemini 2.5 Pro",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gemini-2-5-flash",
|
||||
"Gemini 2.5 Flash",
|
||||
"0.3",
|
||||
"2.5",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
];
|
||||
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input,
|
||||
output,
|
||||
cache_read,
|
||||
cache_creation
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确保模型定价表具备默认数据
|
||||
pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::ensure_model_pricing_seeded_on_conn(&conn)
|
||||
}
|
||||
|
||||
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
|
||||
|
||||
if count == 0 {
|
||||
Self::seed_model_pricing(conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- 辅助方法 ---
|
||||
|
||||
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
|
||||
@@ -247,9 +711,7 @@ impl Database {
|
||||
|
||||
pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
|
||||
if version < 0 {
|
||||
return Err(AppError::Database(
|
||||
"user_version 不能为负数".to_string(),
|
||||
));
|
||||
return Err(AppError::Database("user_version 不能为负数".to_string()));
|
||||
}
|
||||
let sql = format!("PRAGMA user_version = {version};");
|
||||
conn.execute(&sql, [])
|
||||
@@ -261,10 +723,7 @@ impl Database {
|
||||
if s.is_empty() {
|
||||
return Err(AppError::Database(format!("{kind} 不能为空")));
|
||||
}
|
||||
if !s
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return Err(AppError::Database(format!(
|
||||
"非法{kind}: {s},仅允许字母、数字和下划线"
|
||||
)));
|
||||
@@ -292,7 +751,11 @@ impl Database {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool, AppError> {
|
||||
pub(crate) fn has_column(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
Self::validate_identifier(table, "表名")?;
|
||||
Self::validate_identifier(column, "列名")?;
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@ fn migration_rejects_future_version() {
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
Database::set_user_version(&conn, SCHEMA_VERSION + 1).expect("set future version");
|
||||
|
||||
let err = Database::apply_schema_migrations_on_conn(&conn)
|
||||
.expect_err("should reject higher version");
|
||||
let err =
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect_err("should reject higher version");
|
||||
assert!(
|
||||
err.to_string().contains("数据库版本过新"),
|
||||
"unexpected error: {err}"
|
||||
@@ -168,10 +168,7 @@ fn migration_aligns_column_defaults_and_types() {
|
||||
let is_current = get_column_info(&conn, "providers", "is_current");
|
||||
assert_eq!(is_current.r#type, "BOOLEAN");
|
||||
assert_eq!(is_current.notnull, 1);
|
||||
assert_eq!(
|
||||
normalize_default(&is_current.default).as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(normalize_default(&is_current.default).as_deref(), Some("0"));
|
||||
|
||||
let tags = get_column_info(&conn, "mcp_servers", "tags");
|
||||
assert_eq!(tags.r#type, "TEXT");
|
||||
@@ -181,10 +178,7 @@ fn migration_aligns_column_defaults_and_types() {
|
||||
let enabled = get_column_info(&conn, "prompts", "enabled");
|
||||
assert_eq!(enabled.r#type, "BOOLEAN");
|
||||
assert_eq!(enabled.notnull, 1);
|
||||
assert_eq!(
|
||||
normalize_default(&enabled.default).as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(normalize_default(&enabled.default).as_deref(), Some("1"));
|
||||
|
||||
let installed_at = get_column_info(&conn, "skills", "installed_at");
|
||||
assert_eq!(installed_at.r#type, "INTEGER");
|
||||
@@ -251,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
is_proxy_target: Some(false),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -278,3 +273,62 @@ fn dry_run_validates_schema_compatibility() {
|
||||
"Dry-run should succeed with provider data: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_pricing_is_seeded_on_init() {
|
||||
let db = Database::memory().expect("create memory db");
|
||||
|
||||
let conn = db.conn.lock().expect("lock conn");
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
|
||||
.expect("count pricing");
|
||||
|
||||
assert!(
|
||||
count > 0,
|
||||
"模型定价数据应该在初始化时自动填充,实际数量: {}",
|
||||
count
|
||||
);
|
||||
|
||||
// 验证包含 Claude 模型
|
||||
let claude_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("check claude");
|
||||
assert!(
|
||||
claude_count > 0,
|
||||
"应该包含 Claude 模型定价,实际数量: {}",
|
||||
claude_count
|
||||
);
|
||||
|
||||
// 验证包含 GPT 模型
|
||||
let gpt_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("check gpt");
|
||||
assert!(
|
||||
gpt_count > 0,
|
||||
"应该包含 GPT 模型定价,实际数量: {}",
|
||||
gpt_count
|
||||
);
|
||||
|
||||
// 验证包含 Gemini 模型
|
||||
let gemini_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("check gemini");
|
||||
assert!(
|
||||
gemini_count > 0,
|
||||
"应该包含 Gemini 模型定价,实际数量: {}",
|
||||
gemini_count
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,12 +100,8 @@ pub struct DeepLinkImportRequest {
|
||||
/// Skill directory name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub directory: Option<String>,
|
||||
/// Repository branch (default: "main")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub branch: Option<String>,
|
||||
/// Skills subdirectory path (e.g., "skills")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skills_path: Option<String>,
|
||||
|
||||
// ============ Config file fields (v3.8+) ============
|
||||
/// Base64 encoded config content
|
||||
|
||||
@@ -143,7 +143,6 @@ fn parse_provider_deeplink(
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
skills_path: None,
|
||||
config,
|
||||
config_format,
|
||||
config_url,
|
||||
@@ -204,7 +203,6 @@ fn parse_prompt_deeplink(
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
skills_path: None,
|
||||
config: None,
|
||||
config_format: None,
|
||||
config_url: None,
|
||||
@@ -262,7 +260,6 @@ fn parse_mcp_deeplink(
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
skills_path: None,
|
||||
config_url: None,
|
||||
})
|
||||
}
|
||||
@@ -287,10 +284,6 @@ fn parse_skill_deeplink(
|
||||
|
||||
let directory = params.get("directory").cloned();
|
||||
let branch = params.get("branch").cloned();
|
||||
let skills_path = params
|
||||
.get("skills_path")
|
||||
.or_else(|| params.get("skillsPath"))
|
||||
.cloned();
|
||||
|
||||
Ok(DeepLinkImportRequest {
|
||||
version,
|
||||
@@ -298,7 +291,6 @@ fn parse_skill_deeplink(
|
||||
repo: Some(repo),
|
||||
directory,
|
||||
branch,
|
||||
skills_path,
|
||||
icon: None,
|
||||
app: Some("claude".to_string()), // Skills are Claude-only
|
||||
name: None,
|
||||
|
||||
@@ -129,6 +129,7 @@ pub(crate) fn build_provider_from_request(
|
||||
meta: None,
|
||||
icon: request.icon.clone(),
|
||||
icon_color: None,
|
||||
is_proxy_target: None,
|
||||
};
|
||||
|
||||
Ok(provider)
|
||||
|
||||
@@ -40,7 +40,6 @@ pub fn import_skill_from_deeplink(
|
||||
name: name.clone(),
|
||||
branch: request.branch.unwrap_or_else(|| "main".to_string()),
|
||||
enabled: request.enabled.unwrap_or(true),
|
||||
skills_path: request.skills_path,
|
||||
};
|
||||
|
||||
// Save using Database
|
||||
|
||||
@@ -142,7 +142,6 @@ fn test_build_gemini_provider_with_model() {
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
skills_path: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
@@ -189,7 +188,6 @@ fn test_build_gemini_provider_without_model() {
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
skills_path: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
@@ -231,7 +229,6 @@ fn test_parse_and_merge_config_claude() {
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
skills_path: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
@@ -275,7 +272,6 @@ fn test_parse_and_merge_config_url_override() {
|
||||
repo: None,
|
||||
directory: None,
|
||||
branch: None,
|
||||
skills_path: None,
|
||||
content: None,
|
||||
description: None,
|
||||
enabled: None,
|
||||
@@ -307,8 +303,7 @@ fn test_import_prompt_allows_space_in_base64_content() {
|
||||
let db = Arc::new(Database::memory().expect("create memory db"));
|
||||
let state = AppState::new(db.clone());
|
||||
|
||||
let prompt_id =
|
||||
import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
|
||||
let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
|
||||
|
||||
let prompts = state.db.get_prompts("codex").expect("get prompts");
|
||||
let prompt = prompts.get(&prompt_id).expect("prompt saved");
|
||||
@@ -373,12 +368,11 @@ fn test_parse_mcp_deeplink() {
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_deeplink() {
|
||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev&skills_path=src";
|
||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||
let request = parse_deeplink_url(&url).unwrap();
|
||||
|
||||
assert_eq!(request.resource, "skill");
|
||||
assert_eq!(request.repo.unwrap(), "owner/repo");
|
||||
assert_eq!(request.directory.unwrap(), "skills");
|
||||
assert_eq!(request.branch.unwrap(), "dev");
|
||||
assert_eq!(request.skills_path.unwrap(), "src");
|
||||
}
|
||||
|
||||
@@ -91,12 +91,27 @@ impl<T> From<PoisonError<T>> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(err: rusqlite::Error) -> Self {
|
||||
Self::Database(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppError> for String {
|
||||
fn from(err: AppError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for AppError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
|
||||
pub fn format_skill_error(
|
||||
code: &str,
|
||||
|
||||
@@ -38,16 +38,6 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
|
||||
atomic_write(path, json.as_bytes())
|
||||
}
|
||||
|
||||
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
|
||||
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||
let path = user_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||
///
|
||||
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
|
||||
|
||||
@@ -9,7 +9,7 @@ mod config;
|
||||
mod database;
|
||||
mod deeplink;
|
||||
mod error;
|
||||
mod gemini_config; // 新增
|
||||
mod gemini_config;
|
||||
mod gemini_mcp;
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
@@ -17,9 +17,11 @@ mod prompt;
|
||||
mod prompt_files;
|
||||
mod provider;
|
||||
mod provider_defaults;
|
||||
mod proxy;
|
||||
mod services;
|
||||
mod settings;
|
||||
mod store;
|
||||
mod tray;
|
||||
mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||
@@ -46,259 +48,11 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::{ActivationPolicy, RunEvent};
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TrayTexts {
|
||||
show_main: &'static str,
|
||||
no_provider_hint: &'static str,
|
||||
quit: &'static str,
|
||||
}
|
||||
|
||||
impl TrayTexts {
|
||||
fn from_language(language: &str) -> Self {
|
||||
match language {
|
||||
"en" => Self {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
quit: "Quit",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
quit: "退出",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TrayAppSection {
|
||||
app_type: AppType,
|
||||
prefix: &'static str,
|
||||
header_id: &'static str,
|
||||
empty_id: &'static str,
|
||||
header_label: &'static str,
|
||||
log_name: &'static str,
|
||||
}
|
||||
|
||||
const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
header_id: "claude_header",
|
||||
empty_id: "claude_empty",
|
||||
header_label: "─── Claude ───",
|
||||
log_name: "Claude",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Codex,
|
||||
prefix: "codex_",
|
||||
header_id: "codex_header",
|
||||
empty_id: "codex_empty",
|
||||
header_label: "─── Codex ───",
|
||||
log_name: "Codex",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Gemini,
|
||||
prefix: "gemini_",
|
||||
header_id: "gemini_header",
|
||||
empty_id: "gemini_empty",
|
||||
header_label: "─── Gemini ───",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
fn append_provider_section<'a>(
|
||||
app: &'a tauri::AppHandle,
|
||||
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
};
|
||||
|
||||
let header = MenuItem::with_id(
|
||||
app,
|
||||
section.header_id,
|
||||
section.header_label,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&header);
|
||||
|
||||
if manager.providers.is_empty() {
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
section.empty_id,
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (a.created_at, b.created_at) {
|
||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
|
||||
Ok(menu_builder)
|
||||
}
|
||||
|
||||
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
let app_type = section.app_type.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 创建动态托盘菜单
|
||||
fn create_tray_menu(
|
||||
app: &tauri::AppHandle,
|
||||
app_state: &AppState,
|
||||
) -> Result<Menu<tauri::Wry>, AppError> {
|
||||
let app_settings = crate::settings::get_settings();
|
||||
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
|
||||
// 顶部:打开主界面
|
||||
let show_main_item =
|
||||
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
let app_type_str = section.app_type.as_str();
|
||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||
|
||||
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
||||
let current_id = crate::settings::get_effective_current_provider(
|
||||
&app_state.db,
|
||||
§ion.app_type,
|
||||
)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let manager = crate::provider::ProviderManager {
|
||||
providers,
|
||||
current: current_id,
|
||||
};
|
||||
|
||||
menu_builder =
|
||||
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
|
||||
}
|
||||
|
||||
// 分隔符和退出菜单
|
||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||
|
||||
menu_builder = menu_builder.separator().item(&quit_item);
|
||||
|
||||
menu_builder
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||
let desired_policy = if dock_visible {
|
||||
ActivationPolicy::Regular
|
||||
} else {
|
||||
ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
||||
log::warn!("设置 Dock 显示状态失败: {err}");
|
||||
}
|
||||
|
||||
if let Err(err) = app.set_activation_policy(desired_policy) {
|
||||
log::warn!("设置激活策略失败: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理托盘菜单事件
|
||||
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
log::info!("处理托盘菜单事件: {event_id}");
|
||||
|
||||
match event_id {
|
||||
"show_main" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(app, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
log::info!("退出应用");
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {
|
||||
if handle_provider_tray_event(app, event_id) {
|
||||
return;
|
||||
}
|
||||
log::warn!("未处理的菜单事件: {event_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一处理 ccswitch:// 深链接 URL
|
||||
///
|
||||
/// - 解析 URL
|
||||
@@ -358,50 +112,13 @@ fn handle_deeplink_url(
|
||||
true
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// 内部切换供应商函数
|
||||
fn switch_provider_internal(
|
||||
app: &tauri::AppHandle,
|
||||
app_type: crate::app_config::AppType,
|
||||
provider_id: String,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(app_state) = app.try_state::<AppState>() {
|
||||
// 在使用前先保存需要的值
|
||||
let app_type_str = app_type.as_str().to_string();
|
||||
let provider_id_clone = provider_id.clone();
|
||||
|
||||
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
|
||||
.map_err(AppError::Message)?;
|
||||
|
||||
// 切换成功后重新创建托盘菜单
|
||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
log::error!("更新托盘菜单失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发射事件到前端,通知供应商已切换
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"providerId": provider_id_clone
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("发射供应商切换事件失败: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新托盘菜单的Tauri命令
|
||||
#[tauri::command]
|
||||
async fn update_tray_menu(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
match create_tray_menu(&app, state.inner()) {
|
||||
match tray::create_tray_menu(&app, state.inner()) {
|
||||
Ok(new_menu) => {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
tray.set_menu(Some(new_menu))
|
||||
@@ -469,7 +186,7 @@ pub fn run() {
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(window.app_handle(), false);
|
||||
tray::apply_tray_policy(window.app_handle(), false);
|
||||
}
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
@@ -518,7 +235,8 @@ pub fn run() {
|
||||
|
||||
unsafe {
|
||||
use objc2::msg_send;
|
||||
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
||||
let _: () =
|
||||
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to retain NSWindow reference");
|
||||
@@ -614,54 +332,47 @@ pub fn run() {
|
||||
|
||||
let app_state = AppState::new(db);
|
||||
|
||||
// 检查是否需要首次导入(数据库为空)
|
||||
let need_first_import = app_state
|
||||
.db
|
||||
.is_empty_for_first_import()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Failed to check if database is empty: {e}");
|
||||
false
|
||||
});
|
||||
// ============================================================
|
||||
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
|
||||
// ============================================================
|
||||
|
||||
if need_first_import {
|
||||
// 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置
|
||||
log::info!(
|
||||
"Empty database detected, importing existing configurations and initializing defaults..."
|
||||
);
|
||||
|
||||
// 1. 初始化默认 Skills 仓库(3个)
|
||||
match app_state.db.init_default_skill_repos() {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Initialized {count} default skill repositories");
|
||||
}
|
||||
Ok(_) => log::debug!("No default skill repositories to initialize"),
|
||||
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
|
||||
// 1. 初始化默认 Skills 仓库(已有内置检查:表非空则跳过)
|
||||
match app_state.db.init_default_skill_repos() {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Initialized {count} default skill repositories");
|
||||
}
|
||||
Ok(_) => {} // 表非空,静默跳过
|
||||
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
|
||||
}
|
||||
|
||||
// 2. 导入供应商配置(从 live 配置文件)
|
||||
for app in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
] {
|
||||
match crate::services::provider::ProviderService::import_default_config(
|
||||
&app_state,
|
||||
app.clone(),
|
||||
) {
|
||||
Ok(_) => {
|
||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"○ No default provider to import for {}: {}",
|
||||
app.as_str(),
|
||||
e
|
||||
);
|
||||
}
|
||||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
||||
for app in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
] {
|
||||
match crate::services::provider::ProviderService::import_default_config(
|
||||
&app_state,
|
||||
app.clone(),
|
||||
) {
|
||||
Ok(true) => {
|
||||
log::info!("✓ Imported default provider for {}", app.as_str());
|
||||
}
|
||||
Ok(false) => {} // 已有供应商,静默跳过
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"○ No default provider to import for {}: {}",
|
||||
app.as_str(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||||
log::info!("MCP table empty, importing from live configurations...");
|
||||
|
||||
// 3. 导入 MCP 服务器配置
|
||||
match crate::services::mcp::McpService::import_from_claude(&app_state) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} MCP server(s) from Claude");
|
||||
@@ -685,42 +396,28 @@ pub fn run() {
|
||||
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 导入提示词文件
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
// 4. 导入提示词文件(表空时触发)
|
||||
if app_state.db.is_prompts_table_empty().unwrap_or(false) {
|
||||
log::info!("Prompts table empty, importing from live configurations...");
|
||||
|
||||
for app in [
|
||||
crate::app_config::AppType::Claude,
|
||||
) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} prompt(s) from Claude");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No Claude prompt file found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Claude prompt: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
crate::app_config::AppType::Codex,
|
||||
) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} prompt(s) from Codex");
|
||||
}
|
||||
Ok(_) => log::debug!("○ No Codex prompt file found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Codex prompt: {e}"),
|
||||
}
|
||||
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
crate::app_config::AppType::Gemini,
|
||||
) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} prompt(s) from Gemini");
|
||||
] {
|
||||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||||
&app_state,
|
||||
app.clone(),
|
||||
) {
|
||||
Ok(count) if count > 0 => {
|
||||
log::info!("✓ Imported {count} prompt(s) for {}", app.as_str());
|
||||
}
|
||||
Ok(_) => log::debug!("○ No prompt file found for {}", app.as_str()),
|
||||
Err(e) => log::warn!("✗ Failed to import prompt for {}: {e}", app.as_str()),
|
||||
}
|
||||
Ok(_) => log::debug!("○ No Gemini prompt file found to import"),
|
||||
Err(e) => log::warn!("✗ Failed to import Gemini prompt: {e}"),
|
||||
}
|
||||
|
||||
log::info!("First-time import completed");
|
||||
}
|
||||
|
||||
// 迁移旧的 app_config_dir 配置到 Store
|
||||
@@ -789,7 +486,7 @@ pub fn run() {
|
||||
log::info!("✓ Deep-link URL handler registered");
|
||||
|
||||
// 创建动态托盘菜单
|
||||
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
|
||||
|
||||
// 构建托盘
|
||||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||
@@ -800,7 +497,7 @@ pub fn run() {
|
||||
})
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
handle_tray_menu_event(app, &event.id.0);
|
||||
tray::handle_tray_menu_event(app, &event.id.0);
|
||||
})
|
||||
.show_menu_on_left_click(true);
|
||||
|
||||
@@ -825,6 +522,28 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动启动代理服务器
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app_handle.state::<AppState>();
|
||||
match state.db.get_proxy_config().await {
|
||||
Ok(config) => {
|
||||
if config.enabled {
|
||||
log::info!("代理服务配置为启用,正在启动...");
|
||||
match state.proxy_service.start().await {
|
||||
Ok(info) => log::info!(
|
||||
"代理服务器自动启动成功: {}:{}",
|
||||
info.address,
|
||||
info.port
|
||||
),
|
||||
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -834,6 +553,7 @@ pub fn run() {
|
||||
commands::update_provider,
|
||||
commands::delete_provider,
|
||||
commands::switch_provider,
|
||||
commands::set_proxy_target_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
@@ -923,6 +643,31 @@ pub fn run() {
|
||||
// Auto launch
|
||||
commands::set_auto_launch,
|
||||
commands::get_auto_launch_status,
|
||||
// Proxy server management
|
||||
commands::start_proxy_server,
|
||||
commands::stop_proxy_server,
|
||||
commands::get_proxy_status,
|
||||
commands::get_proxy_config,
|
||||
commands::update_proxy_config,
|
||||
commands::is_proxy_running,
|
||||
// Usage statistics
|
||||
commands::get_usage_summary,
|
||||
commands::get_usage_trends,
|
||||
commands::get_provider_stats,
|
||||
commands::get_model_stats,
|
||||
commands::get_request_logs,
|
||||
commands::get_request_detail,
|
||||
commands::get_model_pricing,
|
||||
commands::update_model_pricing,
|
||||
commands::delete_model_pricing,
|
||||
commands::check_provider_limits,
|
||||
// Model testing
|
||||
commands::test_provider_model,
|
||||
commands::test_all_providers_model,
|
||||
commands::get_model_test_config,
|
||||
commands::save_model_test_config,
|
||||
commands::get_model_test_logs,
|
||||
commands::cleanup_model_test_logs,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
@@ -943,7 +688,7 @@ pub fn run() {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
apply_tray_policy(app_handle, true);
|
||||
tray::apply_tray_policy(app_handle, true);
|
||||
}
|
||||
}
|
||||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||||
@@ -1033,21 +778,19 @@ fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
|
||||
|
||||
let message = if is_chinese_locale() {
|
||||
format!(
|
||||
"从旧版本迁移配置时发生错误:\n\n{}\n\n\
|
||||
"从旧版本迁移配置时发生错误:\n\n{error}\n\n\
|
||||
您的数据尚未丢失,旧配置文件仍然保留。\n\
|
||||
建议回退到旧版本 CC Switch 以保护数据。\n\n\
|
||||
点击「重试」重新尝试迁移\n\
|
||||
点击「退出」关闭程序(可回退版本后重新打开)",
|
||||
error
|
||||
点击「退出」关闭程序(可回退版本后重新打开)"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"An error occurred while migrating configuration:\n\n{}\n\n\
|
||||
"An error occurred while migrating configuration:\n\n{error}\n\n\
|
||||
Your data is NOT lost - the old config file is still preserved.\n\
|
||||
Consider rolling back to an older CC Switch version.\n\n\
|
||||
Click 'Retry' to attempt migration again\n\
|
||||
Click 'Exit' to close the program",
|
||||
error
|
||||
Click 'Exit' to close the program"
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Claude MCP 同步和导入模块
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::validation::{extract_server_spec, validate_server_spec};
|
||||
|
||||
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||
let mut out = HashMap::new();
|
||||
for (id, entry) in cfg.servers.iter() {
|
||||
let enabled = entry
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
match extract_server_spec(entry) {
|
||||
Ok(spec) => {
|
||||
out.insert(id.clone(), spec);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
||||
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
let enabled = collect_enabled_servers(&config.mcp.claude);
|
||||
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
||||
let Some(text) = text_opt else { return Ok(0) };
|
||||
|
||||
let v: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
|
||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Claude 应用
|
||||
if !existing.apps.claude {
|
||||
existing.apps.claude = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Claude
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Claude live 配置
|
||||
pub fn sync_single_server_to_claude(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||
let mut updated = current;
|
||||
updated.insert(id.to_string(), server_spec.clone());
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(&updated)
|
||||
}
|
||||
|
||||
/// 从 Claude live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
@@ -1,201 +1,17 @@
|
||||
//! Codex MCP 同步和导入模块
|
||||
//!
|
||||
//! 包含 Codex 的 MCP 配置管理:
|
||||
//! - 从 ~/.codex/config.toml 导入
|
||||
//! - 同步到 ~/.codex/config.toml
|
||||
//! - JSON 到 TOML 的转换逻辑
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
|
||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
|
||||
fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
||||
if !spec.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器连接定义必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
|
||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||
|
||||
if !(is_stdio || is_http || is_sse) {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if is_stdio {
|
||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if cmd.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_http {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_sse {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"sse 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
|
||||
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
|
||||
let obj = entry
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
|
||||
let server = obj
|
||||
.get("server")
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||
validate_server_spec(server)?;
|
||||
|
||||
for key in ["name", "description", "homepage", "docs"] {
|
||||
if let Some(val) = obj.get(key) {
|
||||
if !val.is_string() {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"MCP 服务器 {key} 必须为字符串"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tags) = obj.get("tags") {
|
||||
let arr = tags
|
||||
.as_array()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
|
||||
if !arr.iter().all(|item| item.is_string()) {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 tags 必须为字符串数组".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(enabled) = obj.get("enabled") {
|
||||
if !enabled.is_boolean() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 enabled 必须为布尔值".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
|
||||
let mut change_count = 0usize;
|
||||
let mut renames: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for (key_ref, value) in map.iter_mut() {
|
||||
let key = key_ref.clone();
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let id_value = obj.get("id").cloned();
|
||||
|
||||
let target_id: String;
|
||||
|
||||
match id_value {
|
||||
Some(id_val) => match id_val.as_str() {
|
||||
Some(id_str) => {
|
||||
let trimmed = id_str.trim();
|
||||
if trimmed.is_empty() {
|
||||
obj.insert("id".into(), json!(key.clone()));
|
||||
change_count += 1;
|
||||
target_id = key.clone();
|
||||
} else {
|
||||
if trimmed != id_str {
|
||||
obj.insert("id".into(), json!(trimmed));
|
||||
change_count += 1;
|
||||
}
|
||||
target_id = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
obj.insert("id".into(), json!(key.clone()));
|
||||
change_count += 1;
|
||||
target_id = key.clone();
|
||||
}
|
||||
},
|
||||
None => {
|
||||
obj.insert("id".into(), json!(key.clone()));
|
||||
change_count += 1;
|
||||
target_id = key.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if target_id != key {
|
||||
renames.push((key, target_id));
|
||||
}
|
||||
}
|
||||
|
||||
for (old_key, new_key) in renames {
|
||||
if old_key == new_key {
|
||||
continue;
|
||||
}
|
||||
if map.contains_key(&new_key) {
|
||||
log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
|
||||
if let Some(value) = map.get_mut(&old_key) {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
if obj
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s != old_key)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
obj.insert("id".into(), json!(old_key.clone()));
|
||||
change_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(mut value) = map.remove(&old_key) {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("id".into(), json!(new_key.clone()));
|
||||
}
|
||||
log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'");
|
||||
map.insert(new_key, value);
|
||||
change_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
change_count
|
||||
}
|
||||
|
||||
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
|
||||
let servers = &mut config.mcp_for_mut(app).servers;
|
||||
normalize_server_keys(servers)
|
||||
}
|
||||
|
||||
fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
|
||||
let obj = entry
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
let server = obj
|
||||
.get("server")
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||
|
||||
if !server.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 server 字段必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(server.clone())
|
||||
}
|
||||
use super::validation::{extract_server_spec, validate_server_spec};
|
||||
|
||||
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||
@@ -220,185 +36,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
pub fn get_servers_snapshot_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
) -> (HashMap<String, Value>, usize) {
|
||||
let normalized = normalize_servers_for(config, app);
|
||||
let mut snapshot = config.mcp_for(app).servers.clone();
|
||||
snapshot.retain(|id, value| {
|
||||
let Some(obj) = value.as_object_mut() else {
|
||||
log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
|
||||
return false;
|
||||
};
|
||||
|
||||
obj.entry(String::from("id")).or_insert(json!(id));
|
||||
|
||||
match validate_mcp_entry(value) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
(snapshot, normalized)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
pub fn upsert_in_config_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
id: &str,
|
||||
spec: Value,
|
||||
) -> Result<bool, AppError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
}
|
||||
normalize_servers_for(config, app);
|
||||
validate_mcp_entry(&spec)?;
|
||||
|
||||
let mut entry_obj = spec
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
if let Some(existing_id) = entry_obj.get("id") {
|
||||
let Some(existing_id_str) = existing_id.as_str() else {
|
||||
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
|
||||
};
|
||||
if existing_id_str != id {
|
||||
return Err(AppError::McpValidation(format!(
|
||||
"MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
entry_obj.insert(String::from("id"), json!(id));
|
||||
}
|
||||
|
||||
let value = Value::Object(entry_obj);
|
||||
|
||||
let servers = &mut config.mcp_for_mut(app).servers;
|
||||
let before = servers.get(id).cloned();
|
||||
servers.insert(id.to_string(), value);
|
||||
|
||||
Ok(before.is_none())
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
pub fn delete_in_config_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
id: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
}
|
||||
normalize_servers_for(config, app);
|
||||
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
|
||||
Ok(existed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
|
||||
/// 设置启用状态(不执行落盘或文件同步)
|
||||
pub fn set_enabled_flag_for(
|
||||
config: &mut MultiAppConfig,
|
||||
app: &AppType,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<bool, AppError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
}
|
||||
normalize_servers_for(config, app);
|
||||
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
|
||||
// 写入 enabled 字段
|
||||
let mut obj = spec
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
|
||||
obj.insert("enabled".into(), json!(enabled));
|
||||
*spec = Value::Object(obj);
|
||||
} else {
|
||||
// 若不存在则直接返回 false
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
|
||||
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
let enabled = collect_enabled_servers(&config.mcp.claude);
|
||||
crate::claude_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
|
||||
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
||||
let Some(text) = text_opt else { return Ok(0) };
|
||||
|
||||
let v: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
|
||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Claude 应用
|
||||
if !existing.apps.claude {
|
||||
existing.apps.claude = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Claude
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+)
|
||||
///
|
||||
/// 格式支持:
|
||||
@@ -407,8 +44,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
///
|
||||
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
|
||||
let text = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(0);
|
||||
@@ -697,111 +332,95 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json
|
||||
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
let enabled = collect_enabled_servers(&config.mcp.gemini);
|
||||
crate::gemini_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 ~/.gemini/settings.json 导入 mcpServers 到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
|
||||
let text_opt = crate::gemini_mcp::read_mcp_json()?;
|
||||
let Some(text) = text_opt else { return Ok(0) };
|
||||
|
||||
let v: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 ~/.gemini/settings.json 失败: {e}")))?;
|
||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Gemini 应用
|
||||
if !existing.apps.gemini {
|
||||
existing.apps.gemini = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Gemini
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v3.7.0 新增:单个服务器同步和删除函数
|
||||
// ============================================================================
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Claude live 配置
|
||||
pub fn sync_single_server_to_claude(
|
||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||
pub fn sync_single_server_to_codex(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
use toml_edit::Item;
|
||||
|
||||
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||
let mut updated = current;
|
||||
updated.insert(id.to_string(), server_spec.clone());
|
||||
// 读取现有的 config.toml
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(&updated)
|
||||
let mut doc = if config_path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||
} else {
|
||||
toml_edit::DocumentMut::new()
|
||||
};
|
||||
|
||||
// 清理可能存在的错误格式 [mcp.servers]
|
||||
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
||||
if tbl.contains_key("servers") {
|
||||
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
|
||||
tbl.remove("servers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 [mcp_servers] 表存在
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
// 将 JSON 服务器规范转换为 TOML 表
|
||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||
|
||||
// 使用唯一正确的格式:[mcp_servers]
|
||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||
|
||||
// 写回文件
|
||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 Claude live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
|
||||
/// 从 Codex live 配置中移除单个 MCP 服务器
|
||||
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
|
||||
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
if !config_path.exists() {
|
||||
return Ok(()); // 文件不存在,无需删除
|
||||
}
|
||||
|
||||
// 写回
|
||||
crate::claude_mcp::set_mcp_servers_map(¤t)
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
let mut doc = content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写回文件
|
||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOML 转换辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
|
||||
///
|
||||
/// 支持的类型转换:
|
||||
@@ -1030,117 +649,3 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
|
||||
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||
pub fn sync_single_server_to_codex(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
use toml_edit::Item;
|
||||
|
||||
// 读取现有的 config.toml
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
|
||||
let mut doc = if config_path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||
} else {
|
||||
toml_edit::DocumentMut::new()
|
||||
};
|
||||
|
||||
// 清理可能存在的错误格式 [mcp.servers]
|
||||
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||
if let Some(tbl) = mcp_item.as_table_like_mut() {
|
||||
if tbl.contains_key("servers") {
|
||||
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
|
||||
tbl.remove("servers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 [mcp_servers] 表存在
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
// 将 JSON 服务器规范转换为 TOML 表
|
||||
let toml_table = json_server_to_toml_table(server_spec)?;
|
||||
|
||||
// 使用唯一正确的格式:[mcp_servers]
|
||||
doc["mcp_servers"][id] = Item::Table(toml_table);
|
||||
|
||||
// 写回文件
|
||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 Codex live 配置中移除单个 MCP 服务器
|
||||
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
|
||||
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
let config_path = crate::codex_config::get_codex_config_path();
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(()); // 文件不存在,无需删除
|
||||
}
|
||||
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
let mut doc = content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
mcp_servers.remove(id);
|
||||
}
|
||||
|
||||
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
|
||||
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
|
||||
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
|
||||
if servers.remove(id).is_some() {
|
||||
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写回文件
|
||||
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Gemini live 配置
|
||||
pub fn sync_single_server_to_gemini(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
|
||||
let mut updated = current;
|
||||
updated.insert(id.to_string(), server_spec.clone());
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(&updated)
|
||||
}
|
||||
|
||||
/// 从 Gemini live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Gemini MCP 同步和导入模块
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::validation::{extract_server_spec, validate_server_spec};
|
||||
|
||||
/// 返回已启用的 MCP 服务器(过滤 enabled==true)
|
||||
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
||||
let mut out = HashMap::new();
|
||||
for (id, entry) in cfg.servers.iter() {
|
||||
let enabled = entry
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
match extract_server_spec(entry) {
|
||||
Ok(spec) => {
|
||||
out.insert(id.clone(), spec);
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
|
||||
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
let enabled = collect_enabled_servers(&config.mcp.gemini);
|
||||
crate::gemini_mcp::set_mcp_servers_map(&enabled)
|
||||
}
|
||||
|
||||
/// 从 Gemini MCP 配置导入到统一结构(v3.7.0+)
|
||||
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
let map = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
if map.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 确保新结构存在
|
||||
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||
|
||||
let mut changed = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (id, spec) in map.iter() {
|
||||
// 校验:单项失败不中止,收集错误继续处理
|
||||
if let Err(e) = validate_server_spec(spec) {
|
||||
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
|
||||
errors.push(format!("{id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(existing) = servers.get_mut(id) {
|
||||
// 已存在:仅启用 Gemini 应用
|
||||
if !existing.apps.gemini {
|
||||
existing.apps.gemini = true;
|
||||
changed += 1;
|
||||
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
|
||||
}
|
||||
} else {
|
||||
// 新建服务器:默认仅启用 Gemini
|
||||
servers.insert(
|
||||
id.clone(),
|
||||
McpServer {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
server: spec.clone(),
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: true,
|
||||
},
|
||||
description: None,
|
||||
homepage: None,
|
||||
docs: None,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
);
|
||||
changed += 1;
|
||||
log::info!("导入新 MCP 服务器 '{id}'");
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Gemini live 配置
|
||||
pub fn sync_single_server_to_gemini(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
server_spec: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 添加/更新当前服务器
|
||||
current.insert(id.to_string(), server_spec.clone());
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
|
||||
/// 从 Gemini live 配置中移除单个 MCP 服务器
|
||||
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
|
||||
// 读取现有的 MCP 配置
|
||||
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
|
||||
|
||||
// 移除指定服务器
|
||||
current.remove(id);
|
||||
|
||||
// 写回
|
||||
crate::gemini_mcp::set_mcp_servers_map(¤t)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! MCP (Model Context Protocol) 服务器管理模块
|
||||
//!
|
||||
//! 本模块负责 MCP 服务器配置的验证、同步和导入导出。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//!
|
||||
//! - `validation` - 服务器配置验证
|
||||
//! - `claude` - Claude MCP 同步和导入
|
||||
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
|
||||
//! - `gemini` - Gemini MCP 同步和导入
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
mod validation;
|
||||
|
||||
// 重新导出公共 API
|
||||
pub use claude::{
|
||||
import_from_claude, remove_server_from_claude, sync_enabled_to_claude,
|
||||
sync_single_server_to_claude,
|
||||
};
|
||||
pub use codex::{
|
||||
import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex,
|
||||
};
|
||||
pub use gemini::{
|
||||
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
|
||||
sync_single_server_to_gemini,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
//! MCP 服务器配置验证模块
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
|
||||
pub fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
|
||||
if !spec.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器连接定义必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
let t_opt = spec.get("type").and_then(|x| x.as_str());
|
||||
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
|
||||
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
|
||||
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
|
||||
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
|
||||
|
||||
if !(is_stdio || is_http || is_sse) {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if is_stdio {
|
||||
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if cmd.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_http {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"http 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if is_sse {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.trim().is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
"sse 类型的 MCP 服务器缺少 url 字段".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 MCP 条目中提取服务器规范
|
||||
pub fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
|
||||
let obj = entry
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
|
||||
let server = obj
|
||||
.get("server")
|
||||
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
|
||||
|
||||
if !server.is_object() {
|
||||
return Err(AppError::McpValidation(
|
||||
"MCP 服务器 server 字段必须为 JSON 对象".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(server.clone())
|
||||
}
|
||||
@@ -36,6 +36,10 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
/// 是否为代理目标(数据库专用字段,不写入配置文件)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "isProxyTarget")]
|
||||
pub is_proxy_target: Option<bool>,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -58,6 +62,7 @@ impl Provider {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
is_proxy_target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +156,15 @@ pub struct ProviderMeta {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub partner_promotion_key: Option<String>,
|
||||
/// 成本倍数(用于计算实际成本)
|
||||
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
|
||||
pub cost_multiplier: Option<String>,
|
||||
/// 每日消费限额(USD)
|
||||
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
|
||||
pub limit_daily_usd: Option<String>,
|
||||
/// 每月消费限额(USD)
|
||||
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
|
||||
pub limit_monthly_usd: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProxyError {
|
||||
#[error("服务器已在运行")]
|
||||
AlreadyRunning,
|
||||
|
||||
#[error("服务器未运行")]
|
||||
NotRunning,
|
||||
|
||||
#[error("地址绑定失败: {0}")]
|
||||
BindFailed(String),
|
||||
|
||||
#[error("请求转发失败: {0}")]
|
||||
ForwardFailed(String),
|
||||
|
||||
#[error("无可用的Provider")]
|
||||
NoAvailableProvider,
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("Provider不健康: {0}")]
|
||||
ProviderUnhealthy(String),
|
||||
|
||||
#[error("上游错误 (状态码 {status}): {body:?}")]
|
||||
UpstreamError { status: u16, body: Option<String> },
|
||||
|
||||
#[error("超过最大重试次数")]
|
||||
MaxRetriesExceeded,
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(String),
|
||||
|
||||
#[error("配置错误: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("格式转换错误: {0}")]
|
||||
TransformError(String),
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("无效的请求: {0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
#[error("超时: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// 流式响应空闲超时
|
||||
#[allow(dead_code)]
|
||||
#[error("流式响应空闲超时: {0}秒无数据")]
|
||||
StreamIdleTimeout(u64),
|
||||
|
||||
/// 认证错误
|
||||
#[allow(dead_code)]
|
||||
#[error("认证失败: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("内部错误: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ProxyError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
ProxyError::UpstreamError {
|
||||
status: upstream_status,
|
||||
body: upstream_body,
|
||||
} => {
|
||||
let http_status =
|
||||
StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
|
||||
// 尝试解析上游响应体为 JSON,如果失败则包装为字符串
|
||||
let error_body = if let Some(body_str) = upstream_body {
|
||||
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
|
||||
// 上游返回的是 JSON,直接透传
|
||||
json_body
|
||||
} else {
|
||||
// 上游返回的不是 JSON,包装为错误消息
|
||||
json!({
|
||||
"error": {
|
||||
"message": body_str,
|
||||
"type": "upstream_error",
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
json!({
|
||||
"error": {
|
||||
"message": format!("Upstream error (status {})", upstream_status),
|
||||
"type": "upstream_error",
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
(http_status, error_body)
|
||||
}
|
||||
_ => {
|
||||
let (http_status, message) = match &self {
|
||||
ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()),
|
||||
ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
|
||||
ProxyError::BindFailed(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
|
||||
ProxyError::NoAvailableProvider => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::ProviderUnhealthy(_) => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::MaxRetriesExceeded => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::DatabaseError(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
ProxyError::TransformError(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()),
|
||||
ProxyError::StreamIdleTimeout(_) => {
|
||||
(StatusCode::GATEWAY_TIMEOUT, self.to_string())
|
||||
}
|
||||
ProxyError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
ProxyError::Internal(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
ProxyError::UpstreamError { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
let error_body = json!({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "proxy_error",
|
||||
}
|
||||
});
|
||||
|
||||
(http_status, error_body)
|
||||
}
|
||||
};
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorCategory {
|
||||
/// 可重试错误(网络问题、5xx)
|
||||
Retryable, // 网络超时、5xx 错误
|
||||
/// 不可重试错误(4xx、认证失败)
|
||||
NonRetryable, // 认证失败、参数错误、4xx 错误
|
||||
#[allow(dead_code)]
|
||||
ClientAbort, // 客户端主动中断
|
||||
}
|
||||
|
||||
/// 判断错误是否可重试
|
||||
#[allow(dead_code)]
|
||||
pub fn categorize_error(error: &reqwest::Error) -> ErrorCategory {
|
||||
if error.is_timeout() || error.is_connect() {
|
||||
return ErrorCategory::Retryable;
|
||||
}
|
||||
|
||||
if let Some(status) = error.status() {
|
||||
if status.is_server_error() {
|
||||
ErrorCategory::Retryable
|
||||
} else if status.is_client_error() {
|
||||
ErrorCategory::NonRetryable
|
||||
} else {
|
||||
ErrorCategory::Retryable
|
||||
}
|
||||
} else {
|
||||
ErrorCategory::Retryable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! 请求转发器
|
||||
//!
|
||||
//! 负责将请求转发到上游Provider,支持重试和故障转移
|
||||
|
||||
use super::{
|
||||
error::*,
|
||||
providers::{get_adapter, ProviderAdapter},
|
||||
router::ProviderRouter,
|
||||
types::ProxyStatus,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::{app_config::AppType, database::Database, provider::Provider};
|
||||
use reqwest::{Client, Response};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Client,
|
||||
router: ProviderRouter,
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
pub fn new(
|
||||
db: Arc<Database>,
|
||||
timeout_secs: u64,
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
) -> Self {
|
||||
let mut client_builder = Client::builder();
|
||||
if timeout_secs > 0 {
|
||||
client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
|
||||
}
|
||||
|
||||
let client = client_builder
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
router: ProviderRouter::new(db),
|
||||
max_retries,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
/// 转发请求(带重试和故障转移)
|
||||
pub async fn forward_with_retry(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Response, ProxyError> {
|
||||
let mut failed_ids = Vec::new();
|
||||
let mut failover_happened = false;
|
||||
|
||||
// 获取适配器
|
||||
let adapter = get_adapter(app_type);
|
||||
|
||||
for attempt in 0..self.max_retries {
|
||||
// 选择Provider
|
||||
let provider = self.router.select_provider(app_type, &failed_ids).await?;
|
||||
|
||||
log::debug!(
|
||||
"尝试 {} - 使用Provider: {} ({})",
|
||||
attempt + 1,
|
||||
provider.name,
|
||||
provider.id
|
||||
);
|
||||
|
||||
// 更新状态中的当前Provider信息
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.current_provider = Some(provider.name.clone());
|
||||
status.current_provider_id = Some(provider.id.clone());
|
||||
status.total_requests += 1;
|
||||
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
if attempt > 0 {
|
||||
failover_happened = true;
|
||||
}
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 转发请求
|
||||
match self
|
||||
.forward(&provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let _latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 成功:更新健康状态
|
||||
self.router
|
||||
.update_health(&provider, app_type, true, None)
|
||||
.await;
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
if failover_happened {
|
||||
status.failover_count += 1;
|
||||
}
|
||||
// 重新计算成功率
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
Err(e) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
// 失败:分类错误
|
||||
let category = self.categorize_proxy_error(&e);
|
||||
|
||||
match category {
|
||||
ErrorCategory::Retryable => {
|
||||
// 可重试:更新健康状态,添加到失败列表
|
||||
self.router
|
||||
.update_health(&provider, app_type, false, Some(e.to_string()))
|
||||
.await;
|
||||
failed_ids.push(provider.id.clone());
|
||||
|
||||
// 更新错误信息
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.last_error =
|
||||
Some(format!("Provider {} 失败: {}", provider.name, e));
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"请求失败(可重试): Provider {} - {} - {}ms",
|
||||
provider.name,
|
||||
e,
|
||||
latency
|
||||
);
|
||||
continue;
|
||||
}
|
||||
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
|
||||
// 不可重试:更新失败统计并返回
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
log::error!("请求失败(不可重试): {e}");
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some("已达到最大重试次数".to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate =
|
||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
Err(ProxyError::MaxRetriesExceeded)
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
async fn forward(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<Response, ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let base_url = adapter.extract_base_url(provider)?;
|
||||
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
||||
|
||||
// 使用适配器构建 URL
|
||||
let url = adapter.build_url(&base_url, endpoint);
|
||||
|
||||
// 检查是否需要格式转换
|
||||
let needs_transform = adapter.needs_transform(provider);
|
||||
|
||||
// 记录原始请求 JSON
|
||||
log::info!(
|
||||
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
|
||||
);
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
||||
let transformed = adapter.transform_request(body.clone(), provider)?;
|
||||
log::info!(
|
||||
"[{}] >>> 转换后的请求 JSON:\n{}",
|
||||
adapter.name(),
|
||||
serde_json::to_string_pretty(&transformed).unwrap_or_default()
|
||||
);
|
||||
transformed
|
||||
} else {
|
||||
body.clone()
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[{}] 转发请求: {} -> {}",
|
||||
adapter.name(),
|
||||
provider.name,
|
||||
url
|
||||
);
|
||||
|
||||
// 构建请求
|
||||
let mut request = self.client.post(&url);
|
||||
|
||||
// 只透传必要的 Headers(白名单模式)
|
||||
let allowed_headers = [
|
||||
"accept",
|
||||
"user-agent",
|
||||
"x-request-id",
|
||||
"x-stainless-arch",
|
||||
"x-stainless-lang",
|
||||
"x-stainless-os",
|
||||
"x-stainless-package-version",
|
||||
"x-stainless-runtime",
|
||||
"x-stainless-runtime-version",
|
||||
];
|
||||
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str().to_lowercase();
|
||||
if allowed_headers.contains(&key_str.as_str()) {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 Content-Type 是 json
|
||||
request = request.header("Content-Type", "application/json");
|
||||
|
||||
// 使用适配器添加认证头
|
||||
if let Some(auth) = adapter.extract_auth(provider) {
|
||||
log::debug!(
|
||||
"[{}] 使用认证: {:?} (key: {})",
|
||||
adapter.name(),
|
||||
auth.strategy,
|
||||
auth.masked_key()
|
||||
);
|
||||
request = adapter.add_auth_headers(request, &auth);
|
||||
} else {
|
||||
log::error!(
|
||||
"[{}] 未找到 API Key!Provider: {}",
|
||||
adapter.name(),
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
|
||||
let response = request.json(&request_body).send().await.map_err(|e| {
|
||||
log::error!("[{}] 请求失败: {}", adapter.name(), e);
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
// 检查响应状态
|
||||
let status = response.status();
|
||||
log::info!("[{}] 响应状态: {}", adapter.name(), status);
|
||||
|
||||
if status.is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = response.text().await.ok();
|
||||
log::error!(
|
||||
"[{}] 上游错误 ({}): {:?}",
|
||||
adapter.name(),
|
||||
status_code,
|
||||
body_text
|
||||
);
|
||||
|
||||
Err(ProxyError::UpstreamError {
|
||||
status: status_code,
|
||||
body: body_text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类ProxyError
|
||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||
match error {
|
||||
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
||||
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
|
||||
ProxyError::UpstreamError { status, .. } => {
|
||||
if *status >= 500 {
|
||||
ErrorCategory::Retryable
|
||||
} else if *status >= 400 && *status < 500 {
|
||||
ErrorCategory::NonRetryable
|
||||
} else {
|
||||
ErrorCategory::Retryable
|
||||
}
|
||||
}
|
||||
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
|
||||
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
|
||||
_ => ErrorCategory::NonRetryable,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 健康检查器
|
||||
//!
|
||||
//! 负责定期检查Provider健康状态(占位实现)
|
||||
|
||||
// 占位实现,稍后添加完整逻辑
|
||||
#[allow(dead_code)]
|
||||
pub struct HealthChecker;
|
||||
@@ -0,0 +1,30 @@
|
||||
//! 代理服务器模块
|
||||
//!
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod error;
|
||||
mod forwarder;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod providers;
|
||||
pub mod response_handler;
|
||||
mod router;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
// 公开导出给外部使用(commands, services等模块需要)
|
||||
#[allow(unused_imports)]
|
||||
pub use error::ProxyError;
|
||||
#[allow(unused_imports)]
|
||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||
#[allow(unused_imports)]
|
||||
pub use session::{ClientFormat, ProxySession};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
||||
|
||||
// 内部模块间共享(供子模块使用)
|
||||
// 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use types::*;
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Provider Adapter Trait
|
||||
//!
|
||||
//! 定义供应商适配器的统一接口,抽象不同上游供应商的处理逻辑。
|
||||
|
||||
use super::auth::AuthInfo;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 供应商适配器 Trait
|
||||
///
|
||||
/// 所有供应商适配器都需要实现此 trait,提供统一的接口来处理:
|
||||
/// - URL 构建
|
||||
/// - 认证信息提取和头部注入
|
||||
/// - 请求/响应格式转换(可选)
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub struct ClaudeAdapter;
|
||||
///
|
||||
/// impl ProviderAdapter for ClaudeAdapter {
|
||||
/// fn name(&self) -> &'static str { "Claude" }
|
||||
///
|
||||
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
/// // 从 provider 配置中提取 base_url
|
||||
/// }
|
||||
///
|
||||
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
/// // 从 provider 配置中提取认证信息
|
||||
/// }
|
||||
///
|
||||
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
|
||||
/// }
|
||||
///
|
||||
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
/// // 添加认证头
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait ProviderAdapter: Send + Sync {
|
||||
/// 适配器名称(用于日志和调试)
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// 从 Provider 配置中提取 base_url
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
|
||||
/// * `Err(ProxyError)` - 提取失败
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
|
||||
|
||||
/// 从 Provider 配置中提取认证信息
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(AuthInfo)` - 提取到的认证信息
|
||||
/// * `None` - 未找到认证信息
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
|
||||
|
||||
/// 构建请求 URL
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `base_url` - 基础 URL
|
||||
/// * `endpoint` - 请求端点(如 `/v1/messages`)
|
||||
///
|
||||
/// # Returns
|
||||
/// 完整的请求 URL
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
|
||||
|
||||
/// 添加认证头到请求
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - reqwest RequestBuilder
|
||||
/// * `auth` - 认证信息
|
||||
///
|
||||
/// # Returns
|
||||
/// 添加了认证头的 RequestBuilder
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
||||
|
||||
/// 是否需要格式转换
|
||||
///
|
||||
/// 默认返回 `false`(透传模式)。
|
||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 转换请求体
|
||||
///
|
||||
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
|
||||
/// 默认实现直接返回原始请求体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始请求体
|
||||
/// * `provider` - Provider 配置(用于获取模型映射等)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的请求体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// 转换响应体
|
||||
///
|
||||
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
|
||||
/// 默认实现直接返回原始响应体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始响应体
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的响应体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
///
|
||||
/// Note: 响应转换将在 handler 层集成,目前预留接口
|
||||
#[allow(dead_code)]
|
||||
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Authentication Types
|
||||
//!
|
||||
//! 定义认证信息和认证策略,支持多种上游供应商的认证方式。
|
||||
|
||||
/// 认证信息
|
||||
///
|
||||
/// 包含 API Key 和对应的认证策略
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthInfo {
|
||||
/// API Key
|
||||
pub api_key: String,
|
||||
/// 认证策略
|
||||
pub strategy: AuthStrategy,
|
||||
/// OAuth access_token(用于 GoogleOAuth 策略)
|
||||
pub access_token: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthInfo {
|
||||
/// 创建新的认证信息
|
||||
pub fn new(api_key: String, strategy: AuthStrategy) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
strategy,
|
||||
access_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带有 access_token 的认证信息(用于 OAuth)
|
||||
pub fn with_access_token(api_key: String, access_token: String) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
strategy: AuthStrategy::GoogleOAuth,
|
||||
access_token: Some(access_token),
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回遮蔽后的 API Key(用于日志输出)
|
||||
///
|
||||
/// 显示前4位和后4位,中间用 `...` 代替
|
||||
/// 如果 key 长度不足8位,则返回 `***`
|
||||
pub fn masked_key(&self) -> String {
|
||||
if self.api_key.len() > 8 {
|
||||
format!(
|
||||
"{}...{}",
|
||||
&self.api_key[..4],
|
||||
&self.api_key[self.api_key.len() - 4..]
|
||||
)
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回遮蔽后的 access_token(用于日志输出)
|
||||
#[allow(dead_code)]
|
||||
pub fn masked_access_token(&self) -> Option<String> {
|
||||
self.access_token.as_ref().map(|token| {
|
||||
if token.len() > 8 {
|
||||
format!("{}...{}", &token[..4], &token[token.len() - 4..])
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证策略
|
||||
///
|
||||
/// 不同供应商使用不同的认证方式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthStrategy {
|
||||
/// Anthropic 认证方式
|
||||
/// - Header: `x-api-key: <api_key>`
|
||||
/// - Header: `anthropic-version: 2023-06-01`
|
||||
Anthropic,
|
||||
|
||||
/// Claude 中转服务认证方式(仅 Bearer,无 x-api-key)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <api_key>`
|
||||
///
|
||||
/// 用于不支持 x-api-key 的中转服务
|
||||
ClaudeAuth,
|
||||
|
||||
/// Bearer Token 认证方式(OpenAI 等)
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <api_key>`
|
||||
Bearer,
|
||||
|
||||
/// Google API Key 认证方式
|
||||
///
|
||||
/// - Header: `x-goog-api-key: <api_key>`
|
||||
Google,
|
||||
|
||||
/// Google OAuth 认证方式
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <access_token>`
|
||||
///
|
||||
/// 用于 Gemini CLI 等需要 OAuth 的场景
|
||||
GoogleOAuth,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_long() {
|
||||
let auth = AuthInfo::new("sk-1234567890abcdef".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "sk-1...cdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_short() {
|
||||
let auth = AuthInfo::new("short".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_exactly_8() {
|
||||
let auth = AuthInfo::new("12345678".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "***");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_key_9_chars() {
|
||||
let auth = AuthInfo::new("123456789".to_string(), AuthStrategy::Bearer);
|
||||
assert_eq!(auth.masked_key(), "1234...6789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_strategy_equality() {
|
||||
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
|
||||
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
|
||||
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_info_new_has_no_access_token() {
|
||||
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||||
assert!(auth.access_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_info_with_access_token() {
|
||||
let auth = AuthInfo::with_access_token(
|
||||
"refresh-token".to_string(),
|
||||
"ya29.access-token-12345".to_string(),
|
||||
);
|
||||
assert_eq!(auth.api_key, "refresh-token");
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(
|
||||
auth.access_token,
|
||||
Some("ya29.access-token-12345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_long() {
|
||||
let auth =
|
||||
AuthInfo::with_access_token("refresh".to_string(), "ya29.1234567890abcdef".to_string());
|
||||
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_short() {
|
||||
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
|
||||
assert_eq!(auth.masked_access_token(), Some("***".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masked_access_token_none() {
|
||||
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
|
||||
assert!(auth.masked_access_token().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_auth_strategy() {
|
||||
let auth = AuthInfo::new("sk-test".to_string(), AuthStrategy::ClaudeAuth);
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
assert_ne!(auth.strategy, AuthStrategy::Anthropic);
|
||||
assert_ne!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_google_oauth_strategy() {
|
||||
let auth = AuthInfo::new("refresh-token".to_string(), AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_ne!(auth.strategy, AuthStrategy::Google);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_strategies_are_distinct() {
|
||||
let strategies = [
|
||||
AuthStrategy::Anthropic,
|
||||
AuthStrategy::ClaudeAuth,
|
||||
AuthStrategy::Bearer,
|
||||
AuthStrategy::Google,
|
||||
AuthStrategy::GoogleOAuth,
|
||||
];
|
||||
|
||||
for (i, s1) in strategies.iter().enumerate() {
|
||||
for (j, s2) in strategies.iter().enumerate() {
|
||||
if i == j {
|
||||
assert_eq!(s1, s2);
|
||||
} else {
|
||||
assert_ne!(s1, s2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Claude (Anthropic) Provider Adapter
|
||||
//!
|
||||
//! 支持透传模式和 OpenRouter 兼容模式
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Claude 适配器
|
||||
pub struct ClaudeAdapter;
|
||||
|
||||
impl ClaudeAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 获取供应商类型
|
||||
///
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 OpenRouter
|
||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||
if base_url.contains("openrouter.ai") {
|
||||
return ProviderType::OpenRouter;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 ClaudeAuth (仅 Bearer 认证)
|
||||
if self.is_bearer_only_mode(provider) {
|
||||
return ProviderType::ClaudeAuth;
|
||||
}
|
||||
|
||||
ProviderType::Claude
|
||||
}
|
||||
|
||||
/// 检测是否使用 OpenRouter
|
||||
fn is_openrouter(&self, provider: &Provider) -> bool {
|
||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||
return base_url.contains("openrouter.ai");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测是否为仅 Bearer 认证模式
|
||||
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||||
// 检查 settings_config 中的 auth_mode
|
||||
if let Some(auth_mode) = provider
|
||||
.settings_config
|
||||
.get("auth_mode")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if auth_mode == "bearer_only" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 env 中的 AUTH_MODE
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||||
if auth_mode == "bearer_only" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 从 Provider 配置中提取 API Key
|
||||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// Anthropic 标准 key
|
||||
if let Some(key) = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
// OpenRouter key
|
||||
if let Some(key) = env
|
||||
.get("OPENROUTER_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
// 备选 OpenAI key (用于 OpenRouter)
|
||||
if let Some(key) = env
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
if let Some(key) = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 apiKey/api_key");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
log::warn!("[Claude] 未找到有效的 API Key");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClaudeAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for ClaudeAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"Claude"
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// 1. 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试直接获取
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("apiEndpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
Err(ProxyError::ConfigError(
|
||||
"Claude Provider 缺少 base_url 配置".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
let provider_type = self.provider_type(provider);
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
_ => AuthStrategy::Anthropic,
|
||||
};
|
||||
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, strategy))
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
// OpenRouter 使用 /v1/chat/completions
|
||||
if base_url.contains("openrouter.ai") {
|
||||
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
}
|
||||
|
||||
// Anthropic 直连
|
||||
format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
endpoint.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
match auth.strategy {
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version
|
||||
AuthStrategy::Anthropic => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key)
|
||||
.header("anthropic-version", "2023-06-01"),
|
||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||
AuthStrategy::ClaudeAuth => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
// OpenRouter: Bearer
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
_ => request,
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
self.is_openrouter(provider)
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::anthropic_to_openai(body, provider)
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Claude".to_string(),
|
||||
settings_config: config,
|
||||
website_url: None,
|
||||
category: Some("claude".to_string()),
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
is_proxy_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url_from_env() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}));
|
||||
|
||||
let url = adapter.extract_base_url(&provider).unwrap();
|
||||
assert_eq!(url, "https://api.anthropic.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"OPENROUTER_API_KEY": "sk-or-test-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-or-test-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_claude_auth_mode() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
|
||||
},
|
||||
"auth_mode": "bearer_only"
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_claude_auth_env_mode() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
|
||||
"AUTH_MODE": "bearer_only"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_detection() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// Anthropic 官方
|
||||
let anthropic = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||||
}
|
||||
}));
|
||||
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
|
||||
|
||||
// OpenRouter
|
||||
let openrouter = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"OPENROUTER_API_KEY": "sk-or-test"
|
||||
}
|
||||
}));
|
||||
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
|
||||
|
||||
// ClaudeAuth
|
||||
let claude_auth = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||||
},
|
||||
"auth_mode": "bearer_only"
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&claude_auth),
|
||||
ProviderType::ClaudeAuth
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
let anthropic_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||
|
||||
let openrouter_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
}));
|
||||
assert!(adapter.needs_transform(&openrouter_provider));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Codex (OpenAI) Provider Adapter
|
||||
//!
|
||||
//! 仅透传模式,支持直连 OpenAI API
|
||||
//!
|
||||
//! ## 客户端检测
|
||||
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use regex::Regex;
|
||||
use reqwest::RequestBuilder;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// 官方 Codex 客户端 User-Agent 正则
|
||||
#[allow(dead_code)]
|
||||
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
|
||||
|
||||
/// Codex 适配器
|
||||
pub struct CodexAdapter;
|
||||
|
||||
impl CodexAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 检测是否为官方 Codex 客户端
|
||||
///
|
||||
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
|
||||
#[allow(dead_code)]
|
||||
pub fn is_official_client(user_agent: &str) -> bool {
|
||||
CODEX_CLIENT_REGEX.is_match(user_agent)
|
||||
}
|
||||
|
||||
/// 从 Provider 配置中提取 API Key
|
||||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||||
// 1. 尝试从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
|
||||
if let Some(auth) = provider.settings_config.get("auth") {
|
||||
if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 尝试直接获取
|
||||
if let Some(key) = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
// 4. 尝试从 config 对象中获取
|
||||
if let Some(config) = provider.settings_config.get("config") {
|
||||
if let Some(key) = config
|
||||
.get("api_key")
|
||||
.or_else(|| config.get("apiKey"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodexAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for CodexAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"Codex"
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// 1. 尝试直接获取 base_url 字段
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
// 2. 尝试 baseURL
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
// 3. 尝试从 config 对象中获取
|
||||
if let Some(config) = provider.settings_config.get("config") {
|
||||
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
// 尝试解析 TOML 字符串格式
|
||||
if let Some(config_str) = config.as_str() {
|
||||
if let Some(start) = config_str.find("base_url = \"") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('"') {
|
||||
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
if let Some(start) = config_str.find("base_url = '") {
|
||||
let rest = &config_str[start + 12..];
|
||||
if let Some(end) = rest.find('\'') {
|
||||
return Ok(rest[..end].trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ProxyError::ConfigError(
|
||||
"Codex Provider 缺少 base_url 配置".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
|
||||
// 去除重复的 /v1/v1
|
||||
if url.contains("/v1/v1") {
|
||||
url = url.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Codex".to_string(),
|
||||
settings_config: config,
|
||||
website_url: None,
|
||||
category: Some("codex".to_string()),
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
is_proxy_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url_direct() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"base_url": "https://api.openai.com/v1"
|
||||
}));
|
||||
|
||||
let url = adapter.extract_base_url(&provider).unwrap();
|
||||
assert_eq!(url, "https://api.openai.com/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_from_auth_field() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "sk-test-key-12345678"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-test-key-12345678");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_from_env() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-env-key-12345678"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-env-key-12345678");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url() {
|
||||
let adapter = CodexAdapter::new();
|
||||
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
|
||||
assert_eq!(url, "https://api.openai.com/v1/responses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_dedup_v1() {
|
||||
let adapter = CodexAdapter::new();
|
||||
// base_url 已包含 /v1,endpoint 也包含 /v1
|
||||
let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses");
|
||||
assert_eq!(url, "https://www.packyapi.com/v1/responses");
|
||||
}
|
||||
|
||||
// 官方客户端检测测试
|
||||
#[test]
|
||||
fn test_is_official_client_vscode() {
|
||||
assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0"));
|
||||
assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4"));
|
||||
assert!(CodexAdapter::is_official_client("codex_vscode/0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_official_client_cli() {
|
||||
assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0"));
|
||||
assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_official_client() {
|
||||
assert!(!CodexAdapter::is_official_client("Mozilla/5.0"));
|
||||
assert!(!CodexAdapter::is_official_client("curl/7.68.0"));
|
||||
assert!(!CodexAdapter::is_official_client("python-requests/2.25.1"));
|
||||
assert!(!CodexAdapter::is_official_client("codex_other/1.0.0"));
|
||||
assert!(!CodexAdapter::is_official_client(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_official_client_partial_match() {
|
||||
// 必须从开头匹配
|
||||
assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0"));
|
||||
assert!(!CodexAdapter::is_official_client(
|
||||
"prefix_codex_cli_rs/1.0.0"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Gemini (Google) Provider Adapter
|
||||
//!
|
||||
//! 支持 API Key 和 OAuth 两种认证方式
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Gemini**: API Key 认证 (x-goog-api-key)
|
||||
//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Gemini 适配器
|
||||
pub struct GeminiAdapter;
|
||||
|
||||
/// OAuth 凭证结构
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OAuthCredentials {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub client_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl OAuthCredentials {
|
||||
/// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token)
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
self.refresh_token.is_some() && self.access_token.is_empty()
|
||||
}
|
||||
|
||||
/// 检查是否可以刷新 token
|
||||
pub fn can_refresh(&self) -> bool {
|
||||
self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl GeminiAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 获取供应商类型
|
||||
///
|
||||
/// 根据 API Key 格式检测:
|
||||
/// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证
|
||||
/// - Gemini: 普通 API Key
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
if let Some(key) = self.extract_key_raw(provider) {
|
||||
// OAuth access_token 以 ya29. 开头
|
||||
if key.starts_with("ya29.") {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
// JSON 格式的 OAuth 凭证
|
||||
if key.starts_with('{') {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
|
||||
/// 检测认证类型
|
||||
pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {
|
||||
match self.provider_type(provider) {
|
||||
ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,
|
||||
_ => AuthStrategy::Google,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 OAuth 凭证
|
||||
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
|
||||
// 直接是 access_token
|
||||
if key.starts_with("ya29.") {
|
||||
return Some(OAuthCredentials {
|
||||
access_token: key.to_string(),
|
||||
refresh_token: None,
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
});
|
||||
}
|
||||
|
||||
// JSON 格式
|
||||
if key.starts_with('{') {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {
|
||||
let access_token = json
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let refresh_token = json
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let client_id = json
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let client_secret = json
|
||||
.get("client_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 如果有 access_token 或 refresh_token,返回凭证
|
||||
if !access_token.is_empty() || refresh_token.is_some() {
|
||||
return Some(OAuthCredentials {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client_id,
|
||||
client_secret,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 Provider 配置中提取原始 API Key
|
||||
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// 优先使用 GOOGLE_GEMINI_API_KEY
|
||||
if let Some(key) = env.get("GOOGLE_GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
// 备选 GEMINI_API_KEY
|
||||
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
if let Some(key) = provider
|
||||
.settings_config
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GeminiAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdapter for GeminiAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"Gemini"
|
||||
}
|
||||
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
// 从 env 中获取
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
if let Some(url) = provider
|
||||
.settings_config
|
||||
.get("baseURL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
|
||||
Err(ProxyError::ConfigError(
|
||||
"Gemini Provider 缺少 base_url 配置".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
let key = self.extract_key_raw(provider)?;
|
||||
let strategy = self.detect_auth_type(provider);
|
||||
|
||||
match strategy {
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
// 解析 OAuth 凭证
|
||||
if let Some(creds) = self.parse_oauth_credentials(&key) {
|
||||
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||||
} else {
|
||||
// 回退到普通 API Key
|
||||
Some(AuthInfo::new(key, AuthStrategy::Google))
|
||||
}
|
||||
}
|
||||
_ => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
|
||||
// 处理 /v1beta 路径去重
|
||||
let version_patterns = ["/v1beta", "/v1"];
|
||||
for pattern in &version_patterns {
|
||||
let duplicate = format!("{pattern}{pattern}");
|
||||
if url.contains(&duplicate) {
|
||||
url = url.replace(&duplicate, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
match auth.strategy {
|
||||
// OAuth Bearer 认证
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
request
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||||
}
|
||||
// API Key 认证
|
||||
_ => request.header("x-goog-api-key", &auth.api_key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Gemini".to_string(),
|
||||
settings_config: config,
|
||||
website_url: None,
|
||||
category: Some("gemini".to_string()),
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
is_proxy_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url_from_env() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
}));
|
||||
|
||||
let url = adapter.extract_base_url(&provider).unwrap();
|
||||
assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_api_key() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "AIza-test-key-12345678"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "AIza-test-key-12345678");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||||
assert!(auth.access_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_oauth_access_token() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token-12345"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(
|
||||
auth.access_token,
|
||||
Some("ya29.test-access-token-12345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_oauth_json() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
assert_eq!(auth.access_token, Some("ya29.test-token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_detection() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
|
||||
// API Key
|
||||
let api_key_provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&api_key_provider),
|
||||
ProviderType::Gemini
|
||||
);
|
||||
|
||||
// OAuth access_token
|
||||
let oauth_provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "ya29.test-token"
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&oauth_provider),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
|
||||
// OAuth JSON
|
||||
let oauth_json_provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&oauth_json_provider),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_fallback() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GEMINI_API_KEY": "AIza-fallback-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "AIza-fallback-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_dedup() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
// 模拟 base_url 已包含 /v1beta,endpoint 也包含 /v1beta
|
||||
let url = adapter.build_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-pro:generateContent",
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_normal() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let url = adapter.build_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/models/gemini-pro:generateContent",
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_oauth_credentials_direct_token() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let creds = adapter
|
||||
.parse_oauth_credentials("ya29.test-access-token")
|
||||
.unwrap();
|
||||
assert_eq!(creds.access_token, "ya29.test-access-token");
|
||||
assert!(creds.refresh_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_oauth_credentials_json() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
let creds = adapter
|
||||
.parse_oauth_credentials(
|
||||
"{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//refresh\"}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(creds.access_token, "ya29.test");
|
||||
assert_eq!(creds.refresh_token, Some("1//refresh".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_oauth_credentials_invalid() {
|
||||
let adapter = GeminiAdapter::new();
|
||||
assert!(adapter.parse_oauth_credentials("AIza-api-key").is_none());
|
||||
assert!(adapter.parse_oauth_credentials("invalid-json{").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Provider Adapters Module
|
||||
//!
|
||||
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
|
||||
//!
|
||||
//! ## 模块结构
|
||||
//! - `adapter`: 定义 `ProviderAdapter` trait
|
||||
//! - `auth`: 认证类型和策略
|
||||
//! - `claude`: Claude (Anthropic) 适配器
|
||||
//! - `codex`: Codex (OpenAI) 适配器
|
||||
//! - `gemini`: Gemini (Google) 适配器
|
||||
//! - `models`: API 数据模型
|
||||
//! - `transform`: 格式转换
|
||||
|
||||
mod adapter;
|
||||
mod auth;
|
||||
mod claude;
|
||||
mod codex;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod transform;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::ClaudeAdapter;
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
/// 供应商类型枚举
|
||||
///
|
||||
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
|
||||
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderType {
|
||||
/// Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
Claude,
|
||||
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
ClaudeAuth,
|
||||
/// OpenAI Codex Response API
|
||||
Codex,
|
||||
/// Google Gemini API (x-goog-api-key)
|
||||
Gemini,
|
||||
/// Google Gemini CLI (OAuth Bearer)
|
||||
GeminiCli,
|
||||
/// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
|
||||
OpenRouter,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
/// 是否需要格式转换
|
||||
///
|
||||
/// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
|
||||
#[allow(dead_code)]
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
matches!(self, ProviderType::OpenRouter)
|
||||
}
|
||||
|
||||
/// 获取默认端点
|
||||
#[allow(dead_code)]
|
||||
pub fn default_endpoint(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
|
||||
ProviderType::Codex => "https://api.openai.com",
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 AppType 和 Provider 配置推断供应商类型
|
||||
///
|
||||
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
|
||||
#[allow(dead_code)]
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
// 检测是否为 OpenRouter
|
||||
let adapter = ClaudeAdapter::new();
|
||||
if let Ok(base_url) = adapter.extract_base_url(provider) {
|
||||
if base_url.contains("openrouter.ai") {
|
||||
return ProviderType::OpenRouter;
|
||||
}
|
||||
}
|
||||
// 检测是否为中转服务(仅 Bearer 认证)
|
||||
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
|
||||
// 我们通过检查 settings_config 中的配置来判断
|
||||
// 检查 settings_config 中的 auth_mode
|
||||
if let Some(auth_mode) = provider
|
||||
.settings_config
|
||||
.get("auth_mode")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if auth_mode == "bearer_only" {
|
||||
return ProviderType::ClaudeAuth;
|
||||
}
|
||||
}
|
||||
// 检查 env 中的 auth_mode
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||||
if auth_mode == "bearer_only" {
|
||||
return ProviderType::ClaudeAuth;
|
||||
}
|
||||
}
|
||||
}
|
||||
ProviderType::Claude
|
||||
}
|
||||
AppType::Codex => ProviderType::Codex,
|
||||
AppType::Gemini => {
|
||||
// 检测是否为 CLI 模式(OAuth)
|
||||
let adapter = GeminiAdapter::new();
|
||||
if let Some(auth) = adapter.extract_auth(provider) {
|
||||
let key = &auth.api_key;
|
||||
// OAuth access_token 以 ya29. 开头
|
||||
if key.starts_with("ya29.") {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
// JSON 格式的 OAuth 凭证
|
||||
if key.starts_with('{') {
|
||||
return ProviderType::GeminiCli;
|
||||
}
|
||||
}
|
||||
ProviderType::Gemini
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为字符串表示
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderType::Claude => "claude",
|
||||
ProviderType::ClaudeAuth => "claude_auth",
|
||||
ProviderType::Codex => "codex",
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"claude" => Ok(ProviderType::Claude),
|
||||
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
|
||||
"codex" => Ok(ProviderType::Codex),
|
||||
"gemini" => Ok(ProviderType::Gemini),
|
||||
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
|
||||
"openrouter" => Ok(ProviderType::OpenRouter),
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 AppType 获取对应的适配器
|
||||
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
match app_type {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 ProviderType 获取对应的适配器
|
||||
#[allow(dead_code)]
|
||||
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
|
||||
match provider_type {
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
|
||||
Box::new(ClaudeAdapter::new())
|
||||
}
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn create_provider(config: serde_json::Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Provider".to_string(),
|
||||
settings_config: config,
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
is_proxy_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_needs_transform() {
|
||||
assert!(!ProviderType::Claude.needs_transform());
|
||||
assert!(!ProviderType::ClaudeAuth.needs_transform());
|
||||
assert!(!ProviderType::Codex.needs_transform());
|
||||
assert!(!ProviderType::Gemini.needs_transform());
|
||||
assert!(!ProviderType::GeminiCli.needs_transform());
|
||||
assert!(ProviderType::OpenRouter.needs_transform());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_default_endpoint() {
|
||||
assert_eq!(
|
||||
ProviderType::Claude.default_endpoint(),
|
||||
"https://api.anthropic.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::ClaudeAuth.default_endpoint(),
|
||||
"https://api.anthropic.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::Codex.default_endpoint(),
|
||||
"https://api.openai.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::Gemini.default_endpoint(),
|
||||
"https://generativelanguage.googleapis.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::GeminiCli.default_endpoint(),
|
||||
"https://generativelanguage.googleapis.com"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::OpenRouter.default_endpoint(),
|
||||
"https://openrouter.ai/api"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_from_str() {
|
||||
assert_eq!(
|
||||
"claude".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::Claude
|
||||
);
|
||||
assert_eq!(
|
||||
"claude_auth".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::ClaudeAuth
|
||||
);
|
||||
assert_eq!(
|
||||
"claude-auth".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::ClaudeAuth
|
||||
);
|
||||
assert_eq!(
|
||||
"codex".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::Codex
|
||||
);
|
||||
assert_eq!(
|
||||
"gemini".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::Gemini
|
||||
);
|
||||
assert_eq!(
|
||||
"gemini_cli".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
assert_eq!(
|
||||
"gemini-cli".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GeminiCli
|
||||
);
|
||||
assert_eq!(
|
||||
"openrouter".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::OpenRouter
|
||||
);
|
||||
assert!("invalid".parse::<ProviderType>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_as_str() {
|
||||
assert_eq!(ProviderType::Claude.as_str(), "claude");
|
||||
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
|
||||
assert_eq!(ProviderType::Codex.as_str(), "codex");
|
||||
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
|
||||
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_serde() {
|
||||
// Test serialization
|
||||
let claude = ProviderType::Claude;
|
||||
let serialized = serde_json::to_string(&claude).unwrap();
|
||||
assert_eq!(serialized, "\"claude\"");
|
||||
|
||||
let claude_auth = ProviderType::ClaudeAuth;
|
||||
let serialized = serde_json::to_string(&claude_auth).unwrap();
|
||||
assert_eq!(serialized, "\"claude_auth\"");
|
||||
|
||||
// Test deserialization
|
||||
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
|
||||
assert_eq!(deserialized, ProviderType::Claude);
|
||||
|
||||
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
|
||||
assert_eq!(deserialized, ProviderType::GeminiCli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_claude_direct() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||
assert_eq!(provider_type, ProviderType::Claude);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_claude_openrouter() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"OPENROUTER_API_KEY": "sk-or-test"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||
assert_eq!(provider_type, ProviderType::OpenRouter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_claude_auth() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||||
},
|
||||
"auth_mode": "bearer_only"
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
|
||||
assert_eq!(provider_type, ProviderType::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_codex() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-test"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
|
||||
assert_eq!(provider_type, ProviderType::Codex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_gemini_api_key() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||
assert_eq!(provider_type, ProviderType::Gemini);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_gemini_cli_oauth() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_app_type_gemini_cli_json() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
|
||||
assert_eq!(provider_type, ProviderType::GeminiCli);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_adapter_for_provider_type() {
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
|
||||
assert_eq!(adapter.name(), "Codex");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
|
||||
assert_eq!(adapter.name(), "Gemini");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
|
||||
assert_eq!(adapter.name(), "Gemini");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Anthropic API 数据模型
|
||||
//!
|
||||
//! 用于 Anthropic Messages API 的请求/响应格式转换
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Anthropic 请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<AnthropicMessage>,
|
||||
pub max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<Value>, // 可以是 String 或 Vec<SystemBlock>
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<AnthropicTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
}
|
||||
|
||||
/// Anthropic 消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicMessage {
|
||||
pub role: String,
|
||||
pub content: Value, // String 或 Vec<ContentBlock>
|
||||
}
|
||||
|
||||
/// Anthropic 内容块
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AnthropicContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image")]
|
||||
Image { source: ImageSource },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult { tool_use_id: String, content: Value },
|
||||
}
|
||||
|
||||
/// 图片来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageSource {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: String,
|
||||
pub media_type: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Anthropic 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicTool {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// Anthropic 响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicResponse {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub response_type: String,
|
||||
pub role: String,
|
||||
pub content: Vec<AnthropicResponseContent>,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_sequence: Option<String>,
|
||||
pub usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
/// Anthropic 响应内容
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AnthropicResponseContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// Anthropic 使用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! API 数据模型
|
||||
//!
|
||||
//! 定义 Anthropic 和 OpenAI API 的请求/响应结构
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod openai;
|
||||
@@ -0,0 +1,113 @@
|
||||
//! OpenAI API 数据模型
|
||||
//!
|
||||
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// OpenAI 请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<OpenAIMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<OpenAITool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIMessage {
|
||||
pub role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<Value>, // String 或 Vec<ContentPart>
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<OpenAIToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 内容部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum OpenAIContentPart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: ImageUrl },
|
||||
}
|
||||
|
||||
/// 图片 URL
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageUrl {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// OpenAI 工具调用
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: OpenAIFunction,
|
||||
}
|
||||
|
||||
/// OpenAI 函数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIFunction {
|
||||
pub name: String,
|
||||
pub arguments: String, // JSON 字符串
|
||||
}
|
||||
|
||||
/// OpenAI 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAITool {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: OpenAIFunctionDef,
|
||||
}
|
||||
|
||||
/// OpenAI 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIFunctionDef {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// OpenAI 响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<OpenAIChoice>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<OpenAIUsage>,
|
||||
}
|
||||
|
||||
/// OpenAI 选择
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIChoice {
|
||||
pub index: u32,
|
||||
pub message: OpenAIMessage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 使用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAIUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! 流式响应转换模块
|
||||
//!
|
||||
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
/// OpenAI 流式响应数据结构
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAIStreamChunk {
|
||||
id: String,
|
||||
model: String,
|
||||
choices: Vec<StreamChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<Usage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StreamChoice {
|
||||
delta: Delta,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Delta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning: Option<String>, // OpenRouter 的推理内容
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<DeltaToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct DeltaToolCall {
|
||||
index: usize,
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(rename = "type", default)]
|
||||
call_type: Option<String>,
|
||||
#[serde(default)]
|
||||
function: Option<DeltaFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct DeltaFunction {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI 流式响应的 usage 信息(完整版)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Usage {
|
||||
#[serde(default)]
|
||||
prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
completion_tokens: u32,
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut content_index = 0;
|
||||
let mut has_sent_message_start = false;
|
||||
let mut current_block_type: Option<String> = None;
|
||||
let mut tool_call_id = None;
|
||||
|
||||
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let line = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for l in line.lines() {
|
||||
if let Some(data) = l.strip_prefix("data: ") {
|
||||
if data.trim() == "[DONE]" {
|
||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
let event = json!({"type": "message_stop"});
|
||||
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||
// 记录原始 OpenAI 事件(格式化显示)
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
log::info!(
|
||||
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
|
||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
||||
);
|
||||
} else {
|
||||
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
|
||||
}
|
||||
|
||||
if message_id.is_none() {
|
||||
message_id = Some(chunk.id.clone());
|
||||
}
|
||||
if current_model.is_none() {
|
||||
current_model = Some(chunk.model.clone());
|
||||
}
|
||||
|
||||
if let Some(choice) = chunk.choices.first() {
|
||||
if !has_sent_message_start {
|
||||
let event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id.clone().unwrap_or_default(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: message_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
has_sent_message_start = true;
|
||||
}
|
||||
|
||||
// 处理 reasoning(thinking)
|
||||
if let Some(reasoning) = &choice.delta.reasoning {
|
||||
if current_block_type.is_none() {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("thinking".to_string());
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_block_type.as_deref() != Some("text") {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("text".to_string());
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
if let Some(id) = &tool_call.id {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
tool_call_id = Some(id.clone());
|
||||
}
|
||||
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tool_call_id.clone().unwrap_or_default(),
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("tool_use".to_string());
|
||||
}
|
||||
|
||||
if let Some(args) = &function.arguments {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 finish_reason
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||
// 构建 usage 信息,包含 input_tokens 和 output_tokens
|
||||
let usage_json = chunk.usage.as_ref().map(|u| json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
}));
|
||||
let event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null
|
||||
},
|
||||
"usage": usage_json
|
||||
});
|
||||
let sse_data = format!("event: message_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Stream error: {e}");
|
||||
let error_event = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "stream_error",
|
||||
"message": format!("Stream error: {e}")
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: error\ndata: {}\n\n",
|
||||
serde_json::to_string(&error_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 映射停止原因
|
||||
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
|
||||
finish_reason.map(|r| {
|
||||
match r {
|
||||
"tool_calls" => "tool_use",
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
_ => "end_turn",
|
||||
}
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
//! 格式转换模块
|
||||
//!
|
||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||
//! 参考: anthropic-proxy-rs
|
||||
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 从 Provider 配置中获取模型映射
|
||||
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
|
||||
let env = provider.settings_config.get("env");
|
||||
let model_lower = model.to_lowercase();
|
||||
|
||||
// 检测 thinking 参数
|
||||
let has_thinking = body
|
||||
.get("thinking")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
|
||||
if let Some(env) = env {
|
||||
// 如果启用 thinking,优先使用推理模型
|
||||
if has_thinking {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_REASONING_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
log::debug!("[Transform] 使用推理模型: {m}");
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 根据模型类型选择配置模型
|
||||
if model_lower.contains("haiku") {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
if model_lower.contains("opus") {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
if model_lower.contains("sonnet") {
|
||||
if let Some(m) = env
|
||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
// 默认使用 ANTHROPIC_MODEL
|
||||
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
|
||||
return m.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
model.to_string()
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
|
||||
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
||||
let mapped_model = get_model_from_provider(model, provider, &body);
|
||||
result["model"] = json!(mapped_model);
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// 处理 system prompt
|
||||
if let Some(system) = body.get("system") {
|
||||
if let Some(text) = system.as_str() {
|
||||
// 单个字符串
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
// 多个 system message
|
||||
for msg in arr {
|
||||
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 messages
|
||||
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in msgs {
|
||||
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||||
let content = msg.get("content");
|
||||
let converted = convert_message_to_openai(role, content)?;
|
||||
messages.extend(converted);
|
||||
}
|
||||
}
|
||||
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数
|
||||
if let Some(v) = body.get("max_tokens") {
|
||||
result["max_tokens"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("temperature") {
|
||||
result["temperature"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("top_p") {
|
||||
result["top_p"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("stop_sequences") {
|
||||
result["stop"] = v.clone();
|
||||
}
|
||||
if let Some(v) = body.get("stream") {
|
||||
result["stream"] = v.clone();
|
||||
}
|
||||
|
||||
// 转换 tools (过滤 BatchTool)
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
let openai_tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||
"description": t.get("description"),
|
||||
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !openai_tools.is_empty() {
|
||||
result["tools"] = json!(openai_tools);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = body.get("tool_choice") {
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||
fn convert_message_to_openai(
|
||||
role: &str,
|
||||
content: Option<&Value>,
|
||||
) -> Result<Vec<Value>, ProxyError> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let content = match content {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
result.push(json!({"role": role, "content": null}));
|
||||
return Ok(result);
|
||||
}
|
||||
};
|
||||
|
||||
// 字符串内容
|
||||
if let Some(text) = content.as_str() {
|
||||
result.push(json!({"role": role, "content": text}));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 数组内容(多模态/工具调用)
|
||||
if let Some(blocks) = content.as_array() {
|
||||
let mut content_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for block in blocks {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
content_parts.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
if let Some(source) = block.get("source") {
|
||||
let media_type = source
|
||||
.get("media_type")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("image/png");
|
||||
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
||||
content_parts.push(json!({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
|
||||
}));
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let input = block.get("input").cloned().unwrap_or(json!({}));
|
||||
tool_calls.push(json!({
|
||||
"id": id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": serde_json::to_string(&input).unwrap_or_default()
|
||||
}
|
||||
}));
|
||||
}
|
||||
"tool_result" => {
|
||||
// tool_result 变成单独的 tool role 消息
|
||||
let tool_use_id = block
|
||||
.get("tool_use_id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let content_val = block.get("content");
|
||||
let content_str = match content_val {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
None => String::new(),
|
||||
};
|
||||
result.push(json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_use_id,
|
||||
"content": content_str
|
||||
}));
|
||||
}
|
||||
"thinking" => {
|
||||
// 跳过 thinking blocks
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加带内容和/或工具调用的消息
|
||||
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
||||
let mut msg = json!({"role": role});
|
||||
|
||||
// 内容处理
|
||||
if content_parts.is_empty() {
|
||||
msg["content"] = Value::Null;
|
||||
} else if content_parts.len() == 1 {
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
if !tool_calls.is_empty() {
|
||||
msg["tool_calls"] = json!(tool_calls);
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 其他情况直接透传
|
||||
result.push(json!({"role": role, "content": content}));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 清理 JSON schema(移除不支持的 format)
|
||||
fn clean_schema(mut schema: Value) -> Value {
|
||||
if let Some(obj) = schema.as_object_mut() {
|
||||
// 移除 "format": "uri"
|
||||
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
||||
obj.remove("format");
|
||||
}
|
||||
|
||||
// 递归清理嵌套 schema
|
||||
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
||||
for (_, value) in properties.iter_mut() {
|
||||
*value = clean_schema(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
*items = clean_schema(items.clone());
|
||||
}
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
/// OpenAI 响应 → Anthropic 响应
|
||||
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
let choices = body
|
||||
.get("choices")
|
||||
.and_then(|c| c.as_array())
|
||||
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
|
||||
|
||||
let choice = choices
|
||||
.first()
|
||||
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
|
||||
|
||||
let message = choice
|
||||
.get("message")
|
||||
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
|
||||
// 文本内容
|
||||
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
for tc in tool_calls {
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let empty_obj = json!({});
|
||||
let func = tc.get("function").unwrap_or(&empty_obj);
|
||||
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let args_str = func
|
||||
.get("arguments")
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("{}");
|
||||
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
|
||||
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 映射 finish_reason → stop_reason
|
||||
let stop_reason = choice
|
||||
.get("finish_reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(|r| match r {
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
"tool_calls" => "tool_use",
|
||||
other => other,
|
||||
});
|
||||
|
||||
// usage
|
||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||
let input_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let output_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let result = json!({
|
||||
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
}
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_provider(env_config: Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test Provider".to_string(),
|
||||
settings_config: json!({"env": env_config}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
is_proxy_target: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_openrouter_provider() -> Provider {
|
||||
create_provider(json!({
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_simple() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_system() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": "You are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are a helpful assistant."
|
||||
);
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_tools() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather info",
|
||||
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Let me check"},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_result() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
assert_eq!(msg["content"], "Sunny, 25°C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_simple() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["id"], "chatcmpl-123");
|
||||
assert_eq!(result["type"], "message");
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "Hello!");
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
assert_eq!(result["usage"]["input_tokens"], 10);
|
||||
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_tool_calls() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||
assert_eq!(result["content"][0]["id"], "call_123");
|
||||
assert_eq!(result["content"][0]["name"], "get_weather");
|
||||
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
||||
assert_eq!(result["stop_reason"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_mapping_from_provider() {
|
||||
let provider = create_openrouter_provider();
|
||||
let body = json!({"model": "test"});
|
||||
|
||||
// sonnet 模型
|
||||
assert_eq!(
|
||||
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
|
||||
"anthropic/claude-sonnet-4.5"
|
||||
);
|
||||
|
||||
// haiku 模型
|
||||
assert_eq!(
|
||||
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
|
||||
"anthropic/claude-haiku-4.5"
|
||||
);
|
||||
|
||||
// opus 模型
|
||||
assert_eq!(
|
||||
get_model_from_provider("claude-opus-4-5", &provider, &body),
|
||||
"anthropic/claude-opus-4.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_model_mapping() {
|
||||
let provider = create_openrouter_provider();
|
||||
let input = json!({
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_parameter_detection() {
|
||||
let mut provider = create_openrouter_provider();
|
||||
// 添加推理模型配置
|
||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
||||
}
|
||||
|
||||
let input = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled"},
|
||||
"messages": [{"role": "user", "content": "Solve this problem"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// 应该使用推理模型
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_parameter_disabled() {
|
||||
let mut provider = create_openrouter_provider();
|
||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
||||
}
|
||||
|
||||
let input = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
||||
// 应该使用普通模型
|
||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Response Handler - 统一响应处理
|
||||
//!
|
||||
//! 提供流式和非流式响应的统一处理接口
|
||||
|
||||
use super::session::ProxySession;
|
||||
use super::usage::parser::TokenUsage;
|
||||
use super::ProxyError;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// 响应类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ResponseType {
|
||||
/// 流式响应 (SSE)
|
||||
Stream,
|
||||
/// 非流式响应
|
||||
NonStream,
|
||||
}
|
||||
|
||||
impl ResponseType {
|
||||
/// 从 Content-Type 检测响应类型
|
||||
#[allow(dead_code)]
|
||||
pub fn from_content_type(content_type: &str) -> Self {
|
||||
if content_type.contains("text/event-stream") {
|
||||
ResponseType::Stream
|
||||
} else {
|
||||
ResponseType::NonStream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式响应处理器
|
||||
#[allow(dead_code)]
|
||||
pub struct StreamHandler {
|
||||
/// 空闲超时时间
|
||||
idle_timeout: Duration,
|
||||
/// 收集的事件
|
||||
events: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl StreamHandler {
|
||||
/// 创建新的流式处理器
|
||||
pub fn new(idle_timeout_secs: u64) -> Self {
|
||||
Self {
|
||||
idle_timeout: Duration::from_secs(idle_timeout_secs),
|
||||
events: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式响应,返回分流后的客户端流
|
||||
///
|
||||
/// 客户端流立即返回,内部流在后台收集事件
|
||||
pub fn handle_stream<S>(
|
||||
&self,
|
||||
stream: S,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
{
|
||||
let events = self.events.clone();
|
||||
let idle_timeout = self.idle_timeout;
|
||||
|
||||
async_stream::stream! {
|
||||
let mut _last_activity = Instant::now();
|
||||
let mut buffer = String::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
loop {
|
||||
let chunk_result = timeout(idle_timeout, stream.next()).await;
|
||||
|
||||
match chunk_result {
|
||||
Ok(Some(Ok(bytes))) => {
|
||||
_last_activity = Instant::now();
|
||||
|
||||
// 解析 SSE 事件
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||||
let mut guard = events.lock().await;
|
||||
guard.push(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok(bytes);
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
log::error!("流错误: {e}");
|
||||
yield Err(std::io::Error::other(e.to_string()));
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
// 流结束
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
// 空闲超时
|
||||
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
|
||||
yield Err(std::io::Error::other("Stream idle timeout"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取收集的事件
|
||||
pub async fn get_events(&self) -> Vec<Value> {
|
||||
let guard = self.events.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
/// 从收集的事件中提取 Token 使用量
|
||||
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
|
||||
let events = self.get_events().await;
|
||||
|
||||
match session.client_format {
|
||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
|
||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
|
||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||
TokenUsage::from_gemini_stream_chunks(&events)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 非流式响应处理器
|
||||
#[allow(dead_code)]
|
||||
pub struct NonStreamHandler;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NonStreamHandler {
|
||||
/// 处理非流式响应
|
||||
///
|
||||
/// 克隆响应体用于后台解析,原始响应立即返回
|
||||
pub async fn handle_response(
|
||||
body: &[u8],
|
||||
session: &ProxySession,
|
||||
) -> Result<Option<TokenUsage>, ProxyError> {
|
||||
let json: Value = serde_json::from_slice(body)
|
||||
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
|
||||
|
||||
let usage = match session.client_format {
|
||||
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
|
||||
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
|
||||
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
|
||||
TokenUsage::from_gemini_response(&json)
|
||||
}
|
||||
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(usage)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一响应分发器
|
||||
#[allow(dead_code)]
|
||||
pub struct ResponseDispatcher;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ResponseDispatcher {
|
||||
/// 判断响应类型
|
||||
pub fn detect_type(content_type: &str) -> ResponseType {
|
||||
ResponseType::from_content_type(content_type)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_response_type_detection() {
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("text/event-stream"),
|
||||
ResponseType::Stream
|
||||
);
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
|
||||
ResponseType::Stream
|
||||
);
|
||||
assert_eq!(
|
||||
ResponseType::from_content_type("application/json"),
|
||||
ResponseType::NonStream
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_handler_creation() {
|
||||
let handler = StreamHandler::new(30);
|
||||
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Provider路由器
|
||||
//!
|
||||
//! 负责选择合适的Provider进行请求转发
|
||||
|
||||
use super::ProxyError;
|
||||
use crate::{app_config::AppType, database::Database, provider::Provider};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ProviderRouter {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl ProviderRouter {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 选择Provider(只使用标记为代理目标的 Provider)
|
||||
pub async fn select_provider(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
_failed_ids: &[String],
|
||||
) -> Result<Provider, ProxyError> {
|
||||
// 1. 获取 Proxy Target Provider ID
|
||||
let proxy_target_id = self
|
||||
.db
|
||||
.get_proxy_target_provider(app_type.as_str())
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let target_id = proxy_target_id.ok_or_else(|| {
|
||||
log::warn!("[{}] 未设置代理目标 Provider", app_type.as_str());
|
||||
ProxyError::NoAvailableProvider
|
||||
})?;
|
||||
|
||||
// 2. 获取所有 Provider
|
||||
let providers = self
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
// 3. 找到目标 Provider
|
||||
let target = providers.get(&target_id).ok_or_else(|| {
|
||||
log::warn!(
|
||||
"[{}] 代理目标 Provider 不存在: {}",
|
||||
app_type.as_str(),
|
||||
target_id
|
||||
);
|
||||
ProxyError::NoAvailableProvider
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"[{}] 使用代理目标 Provider: {}",
|
||||
app_type.as_str(),
|
||||
target.name
|
||||
);
|
||||
Ok(target.clone())
|
||||
}
|
||||
|
||||
/// 更新Provider健康状态(保留接口但不影响选择)
|
||||
pub async fn update_health(
|
||||
&self,
|
||||
_provider: &Provider,
|
||||
_app_type: &AppType,
|
||||
_success: bool,
|
||||
_error_msg: Option<String>,
|
||||
) {
|
||||
// 不再记录健康状态
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! HTTP代理服务器
|
||||
//!
|
||||
//! 基于Axum的HTTP服务器,处理代理请求
|
||||
|
||||
use super::{handlers, types::*, ProxyError};
|
||||
use crate::database::Database;
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{oneshot, RwLock};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
/// 代理服务器状态(共享)
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyState {
|
||||
pub db: Arc<Database>,
|
||||
pub config: Arc<RwLock<ProxyConfig>>,
|
||||
pub status: Arc<RwLock<ProxyStatus>>,
|
||||
pub start_time: Arc<RwLock<Option<std::time::Instant>>>,
|
||||
}
|
||||
|
||||
/// 代理HTTP服务器
|
||||
pub struct ProxyServer {
|
||||
config: ProxyConfig,
|
||||
state: ProxyState,
|
||||
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
}
|
||||
|
||||
impl ProxyServer {
|
||||
pub fn new(config: ProxyConfig, db: Arc<Database>) -> Self {
|
||||
let state = ProxyState {
|
||||
db,
|
||||
config: Arc::new(RwLock::new(config.clone())),
|
||||
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
};
|
||||
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {
|
||||
// 检查是否已在运行
|
||||
if self.shutdown_tx.read().await.is_some() {
|
||||
return Err(ProxyError::AlreadyRunning);
|
||||
}
|
||||
|
||||
let addr: SocketAddr =
|
||||
format!("{}:{}", self.config.listen_address, self.config.listen_port)
|
||||
.parse()
|
||||
.map_err(|e| ProxyError::BindFailed(format!("无效的地址: {e}")))?;
|
||||
|
||||
// 创建关闭通道
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
|
||||
// 构建路由
|
||||
let app = self.build_router();
|
||||
|
||||
// 绑定监听器
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
||||
|
||||
log::info!("代理服务器启动于 {addr}");
|
||||
|
||||
// 保存关闭句柄
|
||||
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||
|
||||
// 更新状态
|
||||
let mut status = self.state.status.write().await;
|
||||
status.running = true;
|
||||
status.address = self.config.listen_address.clone();
|
||||
status.port = self.config.listen_port;
|
||||
drop(status);
|
||||
|
||||
// 记录启动时间
|
||||
*self.state.start_time.write().await = Some(std::time::Instant::now());
|
||||
|
||||
// 启动服务器
|
||||
let state = self.state.clone();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
shutdown_rx.await.ok();
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// 服务器停止后更新状态
|
||||
state.status.write().await.running = false;
|
||||
*state.start_time.write().await = None;
|
||||
});
|
||||
|
||||
Ok(ProxyServerInfo {
|
||||
address: self.config.listen_address.clone(),
|
||||
port: self.config.listen_port,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<(), ProxyError> {
|
||||
if let Some(tx) = self.shutdown_tx.write().await.take() {
|
||||
let _ = tx.send(());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ProxyError::NotRunning)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_status(&self) -> ProxyStatus {
|
||||
let mut status = self.state.status.read().await.clone();
|
||||
|
||||
// 计算运行时间
|
||||
if let Some(start) = *self.state.start_time.read().await {
|
||||
status.uptime_seconds = start.elapsed().as_secs();
|
||||
}
|
||||
|
||||
// 获取所有活跃的代理目标
|
||||
if let Ok(targets) = self.state.db.get_all_proxy_targets() {
|
||||
status.active_targets = targets
|
||||
.into_iter()
|
||||
.map(|(app_type, name, id)| ActiveTarget {
|
||||
app_type,
|
||||
provider_name: name,
|
||||
provider_id: id,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
|
||||
fn build_router(&self) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
// 健康检查
|
||||
.route("/health", get(handlers::health_check))
|
||||
.route("/status", get(handlers::get_status))
|
||||
// Claude API
|
||||
.route("/v1/messages", post(handlers::handle_messages))
|
||||
// OpenAI Chat Completions API (Codex CLI)
|
||||
.route(
|
||||
"/v1/chat/completions",
|
||||
post(handlers::handle_chat_completions),
|
||||
)
|
||||
// OpenAI Responses API (Codex CLI)
|
||||
.route("/v1/responses", post(handlers::handle_responses))
|
||||
// Gemini API
|
||||
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||
.layer(cors)
|
||||
.with_state(self.state.clone())
|
||||
}
|
||||
|
||||
/// 在不重启服务的情况下更新运行时配置
|
||||
pub async fn apply_runtime_config(&self, config: &ProxyConfig) {
|
||||
*self.state.config.write().await = config.clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Proxy Session - 请求会话管理
|
||||
//!
|
||||
//! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。
|
||||
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 客户端请求格式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ClientFormat {
|
||||
/// Claude Messages API (/v1/messages)
|
||||
Claude,
|
||||
/// Codex Response API (/v1/responses)
|
||||
Codex,
|
||||
/// OpenAI Chat Completions API (/v1/chat/completions)
|
||||
OpenAI,
|
||||
/// Gemini API (/v1beta/models/*/generateContent)
|
||||
Gemini,
|
||||
/// Gemini CLI API (/v1internal/models/*/generateContent)
|
||||
GeminiCli,
|
||||
/// 未知格式
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ClientFormat {
|
||||
/// 从请求路径检测格式
|
||||
pub fn from_path(path: &str) -> Self {
|
||||
if path.contains("/v1/messages") {
|
||||
ClientFormat::Claude
|
||||
} else if path.contains("/v1/responses") {
|
||||
ClientFormat::Codex
|
||||
} else if path.contains("/v1/chat/completions") {
|
||||
ClientFormat::OpenAI
|
||||
} else if path.contains("/v1internal/") && path.contains("generateContent") {
|
||||
// Gemini CLI 使用 /v1internal/ 路径
|
||||
ClientFormat::GeminiCli
|
||||
} else if (path.contains("/v1beta/") || path.contains("/v1/"))
|
||||
&& path.contains("generateContent")
|
||||
{
|
||||
// Gemini API 使用 /v1beta/ 或 /v1/ 路径
|
||||
ClientFormat::Gemini
|
||||
} else if path.contains("generateContent") {
|
||||
// 通用 Gemini 端点
|
||||
ClientFormat::Gemini
|
||||
} else {
|
||||
ClientFormat::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求体内容检测格式(回退方案)
|
||||
pub fn from_body(body: &serde_json::Value) -> Self {
|
||||
// Claude 格式特征: messages 数组 + model 字段 + 无 response_format
|
||||
if body.get("messages").is_some()
|
||||
&& body.get("model").is_some()
|
||||
&& body.get("response_format").is_none()
|
||||
&& body.get("contents").is_none()
|
||||
{
|
||||
// 区分 Claude 和 OpenAI
|
||||
if body.get("max_tokens").is_some() {
|
||||
return ClientFormat::Claude;
|
||||
}
|
||||
return ClientFormat::OpenAI;
|
||||
}
|
||||
|
||||
// Codex 格式特征: input 字段
|
||||
if body.get("input").is_some() {
|
||||
return ClientFormat::Codex;
|
||||
}
|
||||
|
||||
// Gemini 格式特征: contents 数组
|
||||
if body.get("contents").is_some() {
|
||||
return ClientFormat::Gemini;
|
||||
}
|
||||
|
||||
ClientFormat::Unknown
|
||||
}
|
||||
|
||||
/// 转换为字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ClientFormat::Claude => "claude",
|
||||
ClientFormat::Codex => "codex",
|
||||
ClientFormat::OpenAI => "openai",
|
||||
ClientFormat::Gemini => "gemini",
|
||||
ClientFormat::GeminiCli => "gemini_cli",
|
||||
ClientFormat::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientFormat {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 代理会话
|
||||
///
|
||||
/// 包含请求全生命周期的上下文数据
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ProxySession {
|
||||
/// 唯一会话 ID
|
||||
pub session_id: String,
|
||||
/// 请求开始时间
|
||||
pub start_time: Instant,
|
||||
/// HTTP 方法
|
||||
pub method: String,
|
||||
/// 请求 URL
|
||||
pub request_url: String,
|
||||
/// User-Agent
|
||||
pub user_agent: Option<String>,
|
||||
/// 客户端请求格式
|
||||
pub client_format: ClientFormat,
|
||||
/// 选定的供应商 ID
|
||||
pub provider_id: Option<String>,
|
||||
/// 模型名称
|
||||
pub model: Option<String>,
|
||||
/// 是否为流式请求
|
||||
pub is_streaming: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ProxySession {
|
||||
/// 从请求创建会话
|
||||
pub fn from_request(
|
||||
method: &str,
|
||||
request_url: &str,
|
||||
user_agent: Option<&str>,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> Self {
|
||||
// 检测客户端格式
|
||||
let mut client_format = ClientFormat::from_path(request_url);
|
||||
if client_format == ClientFormat::Unknown {
|
||||
if let Some(body) = body {
|
||||
client_format = ClientFormat::from_body(body);
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否为流式请求
|
||||
let is_streaming = body
|
||||
.and_then(|b| b.get("stream"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 提取模型名称
|
||||
let model = body
|
||||
.and_then(|b| b.get("model"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Self {
|
||||
session_id: Uuid::new_v4().to_string(),
|
||||
start_time: Instant::now(),
|
||||
method: method.to_string(),
|
||||
request_url: request_url.to_string(),
|
||||
user_agent: user_agent.map(|s| s.to_string()),
|
||||
client_format,
|
||||
provider_id: None,
|
||||
model,
|
||||
is_streaming,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置供应商 ID
|
||||
pub fn with_provider(mut self, provider_id: &str) -> Self {
|
||||
self.provider_id = Some(provider_id.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 获取请求延迟(毫秒)
|
||||
pub fn latency_ms(&self) -> u64 {
|
||||
self.start_time.elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_claude() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/messages"),
|
||||
ClientFormat::Claude
|
||||
);
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/api/v1/messages"),
|
||||
ClientFormat::Claude
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_codex() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/responses"),
|
||||
ClientFormat::Codex
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_openai() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1/chat/completions"),
|
||||
ClientFormat::OpenAI
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_gemini() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"),
|
||||
ClientFormat::Gemini
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_path_gemini_cli() {
|
||||
assert_eq!(
|
||||
ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"),
|
||||
ClientFormat::GeminiCli
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_claude() {
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 1024
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_codex() {
|
||||
let body = json!({
|
||||
"input": "Write a function"
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_from_body_gemini() {
|
||||
let body = json!({
|
||||
"contents": [{"parts": [{"text": "Hello"}]}]
|
||||
});
|
||||
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_id_uniqueness() {
|
||||
let session1 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||
let session2 = ProxySession::from_request("POST", "/v1/messages", None, None);
|
||||
assert_ne!(session1.session_id, session2.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_from_request() {
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"max_tokens": 1024,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let session =
|
||||
ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body));
|
||||
|
||||
assert_eq!(session.method, "POST");
|
||||
assert_eq!(session.request_url, "/v1/messages");
|
||||
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
|
||||
assert_eq!(session.client_format, ClientFormat::Claude);
|
||||
assert_eq!(session.model, Some("claude-3-5-sonnet".to_string()));
|
||||
assert!(session.is_streaming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_with_provider() {
|
||||
let session = ProxySession::from_request("POST", "/v1/messages", None, None)
|
||||
.with_provider("provider-123");
|
||||
|
||||
assert_eq!(session.provider_id, Some("provider-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_format_as_str() {
|
||||
assert_eq!(ClientFormat::Claude.as_str(), "claude");
|
||||
assert_eq!(ClientFormat::Codex.as_str(), "codex");
|
||||
assert_eq!(ClientFormat::OpenAI.as_str(), "openai");
|
||||
assert_eq!(ClientFormat::Gemini.as_str(), "gemini");
|
||||
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 代理服务器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyConfig {
|
||||
/// 是否启用代理服务
|
||||
pub enabled: bool,
|
||||
/// 监听地址
|
||||
pub listen_address: String,
|
||||
/// 监听端口
|
||||
pub listen_port: u16,
|
||||
/// 最大重试次数
|
||||
pub max_retries: u8,
|
||||
/// 请求超时时间(秒)
|
||||
pub request_timeout: u64,
|
||||
/// 是否启用日志
|
||||
pub enable_logging: bool,
|
||||
}
|
||||
|
||||
impl Default for ProxyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
listen_address: "127.0.0.1".to_string(),
|
||||
listen_port: 15721, // 使用较少占用的高位端口
|
||||
max_retries: 3,
|
||||
request_timeout: 300,
|
||||
enable_logging: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 代理服务器状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProxyStatus {
|
||||
/// 是否运行中
|
||||
pub running: bool,
|
||||
/// 监听地址
|
||||
pub address: String,
|
||||
/// 监听端口
|
||||
pub port: u16,
|
||||
/// 活跃连接数
|
||||
pub active_connections: usize,
|
||||
/// 总请求数
|
||||
pub total_requests: u64,
|
||||
/// 成功请求数
|
||||
pub success_requests: u64,
|
||||
/// 失败请求数
|
||||
pub failed_requests: u64,
|
||||
/// 成功率 (0-100)
|
||||
pub success_rate: f32,
|
||||
/// 运行时间(秒)
|
||||
pub uptime_seconds: u64,
|
||||
/// 当前使用的Provider名称
|
||||
pub current_provider: Option<String>,
|
||||
/// 当前Provider的ID
|
||||
pub current_provider_id: Option<String>,
|
||||
/// 最后一次请求时间
|
||||
pub last_request_at: Option<String>,
|
||||
/// 最后一次错误信息
|
||||
pub last_error: Option<String>,
|
||||
/// Provider故障转移次数
|
||||
pub failover_count: u64,
|
||||
/// 当前活跃的代理目标列表
|
||||
#[serde(default)]
|
||||
pub active_targets: Vec<ActiveTarget>,
|
||||
}
|
||||
|
||||
/// 活跃的代理目标信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActiveTarget {
|
||||
pub app_type: String, // "Claude" | "Codex" | "Gemini"
|
||||
pub provider_name: String,
|
||||
pub provider_id: String,
|
||||
}
|
||||
|
||||
/// 代理服务器信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyServerInfo {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub started_at: String,
|
||||
}
|
||||
|
||||
/// API 格式类型(预留,当前不需要格式转换)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ApiFormat {
|
||||
Claude,
|
||||
OpenAI,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
/// Provider健康状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderHealth {
|
||||
pub provider_id: String,
|
||||
pub app_type: String,
|
||||
pub is_healthy: bool,
|
||||
pub consecutive_failures: u32,
|
||||
pub last_success_at: Option<String>,
|
||||
pub last_failure_at: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 使用统计记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyUsageRecord {
|
||||
pub provider_id: String,
|
||||
pub app_type: String,
|
||||
pub endpoint: String,
|
||||
pub request_tokens: Option<i32>,
|
||||
pub response_tokens: Option<i32>,
|
||||
pub status_code: u16,
|
||||
pub latency_ms: u64,
|
||||
pub error: Option<String>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Cost Calculator - 计算 API 请求成本
|
||||
//!
|
||||
//! 使用高精度 Decimal 类型避免浮点数精度问题
|
||||
|
||||
use super::parser::TokenUsage;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// 成本明细
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CostBreakdown {
|
||||
pub input_cost: Decimal,
|
||||
pub output_cost: Decimal,
|
||||
pub cache_read_cost: Decimal,
|
||||
pub cache_creation_cost: Decimal,
|
||||
pub total_cost: Decimal,
|
||||
}
|
||||
|
||||
/// 模型定价信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelPricing {
|
||||
pub input_cost_per_million: Decimal,
|
||||
pub output_cost_per_million: Decimal,
|
||||
pub cache_read_cost_per_million: Decimal,
|
||||
pub cache_creation_cost_per_million: Decimal,
|
||||
}
|
||||
|
||||
/// 成本计算器
|
||||
pub struct CostCalculator;
|
||||
|
||||
impl CostCalculator {
|
||||
/// 计算请求成本
|
||||
///
|
||||
/// # 参数
|
||||
/// - `usage`: Token 使用量
|
||||
/// - `pricing`: 模型定价
|
||||
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
|
||||
pub fn calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let million = Decimal::from(1_000_000);
|
||||
|
||||
let input_cost = Decimal::from(usage.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
|
||||
* cost_multiplier;
|
||||
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
|
||||
* pricing.cache_creation_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
|
||||
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||||
|
||||
CostBreakdown {
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试计算成本,如果模型未知则返回 None
|
||||
pub fn try_calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: Option<&ModelPricing>,
|
||||
cost_multiplier: Decimal,
|
||||
) -> Option<CostBreakdown> {
|
||||
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelPricing {
|
||||
/// 从字符串创建定价信息
|
||||
pub fn from_strings(
|
||||
input: &str,
|
||||
output: &str,
|
||||
cache_read: &str,
|
||||
cache_creation: &str,
|
||||
) -> Result<Self, rust_decimal::Error> {
|
||||
Ok(Self {
|
||||
input_cost_per_million: Decimal::from_str(input)?,
|
||||
output_cost_per_million: Decimal::from_str(output)?,
|
||||
cache_read_cost_per_million: Decimal::from_str(cache_read)?,
|
||||
cache_creation_cost_per_million: Decimal::from_str(cache_creation)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: 200,
|
||||
cache_creation_tokens: 100,
|
||||
model: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// input: 1000 * 3.0 / 1M = 0.003
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||
// output: 500 * 15.0 / 1M = 0.0075
|
||||
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
|
||||
// cache_read: 200 * 0.3 / 1M = 0.00006
|
||||
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
|
||||
// cache_creation: 100 * 3.75 / 1M = 0.000375
|
||||
assert_eq!(
|
||||
cost.cache_creation_cost,
|
||||
Decimal::from_str("0.000375").unwrap()
|
||||
);
|
||||
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_multiplier() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
|
||||
let multiplier = Decimal::from_str("1.5").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_model_handling() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
};
|
||||
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
let cost = CostCalculator::try_calculate(&usage, None, multiplier);
|
||||
|
||||
assert!(cost.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decimal_precision() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 1,
|
||||
cache_creation_tokens: 1,
|
||||
model: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// 验证高精度计算
|
||||
assert!(cost.total_cost > Decimal::ZERO);
|
||||
assert!(cost.total_cost.to_string().len() > 2); // 确保保留了小数位
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Usage Logger - 记录 API 请求使用情况
|
||||
|
||||
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||
use super::parser::TokenUsage;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::find_model_pricing_row;
|
||||
use rust_decimal::Decimal;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 请求日志
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestLog {
|
||||
pub request_id: String,
|
||||
pub provider_id: String,
|
||||
pub app_type: String,
|
||||
pub model: String,
|
||||
pub usage: TokenUsage,
|
||||
pub cost: Option<CostBreakdown>,
|
||||
pub latency_ms: u64,
|
||||
pub first_token_ms: Option<u64>,
|
||||
pub status_code: u16,
|
||||
pub error_message: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
/// 供应商类型 (claude, claude_auth, codex, gemini, gemini_cli, openrouter)
|
||||
pub provider_type: Option<String>,
|
||||
/// 是否为流式请求
|
||||
pub is_streaming: bool,
|
||||
/// 成本倍数
|
||||
pub cost_multiplier: String,
|
||||
}
|
||||
|
||||
/// 使用量记录器
|
||||
pub struct UsageLogger<'a> {
|
||||
db: &'a Database,
|
||||
}
|
||||
|
||||
impl<'a> UsageLogger<'a> {
|
||||
pub fn new(db: &'a Database) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// 记录成功的请求
|
||||
pub fn log_request(&self, log: &RequestLog) -> Result<(), AppError> {
|
||||
let conn = crate::database::lock_conn!(self.db.conn);
|
||||
|
||||
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) =
|
||||
if let Some(cost) = &log.cost {
|
||||
(
|
||||
cost.input_cost.to_string(),
|
||||
cost.output_cost.to_string(),
|
||||
cost.cache_read_cost.to_string(),
|
||||
cost.cache_creation_cost.to_string(),
|
||||
cost.total_cost.to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
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)",
|
||||
rusqlite::params![
|
||||
log.request_id,
|
||||
log.provider_id,
|
||||
log.app_type,
|
||||
log.model,
|
||||
log.usage.input_tokens,
|
||||
log.usage.output_tokens,
|
||||
log.usage.cache_read_tokens,
|
||||
log.usage.cache_creation_tokens,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
log.latency_ms as i64,
|
||||
log.first_token_ms.map(|v| v as i64),
|
||||
log.status_code as i64,
|
||||
log.error_message,
|
||||
log.session_id,
|
||||
log.provider_type,
|
||||
log.is_streaming as i64,
|
||||
log.cost_multiplier,
|
||||
created_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录失败的请求
|
||||
#[allow(dead_code, clippy::too_many_arguments)]
|
||||
pub fn log_error(
|
||||
&self,
|
||||
request_id: String,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
model: String,
|
||||
status_code: u16,
|
||||
error_message: String,
|
||||
latency_ms: u64,
|
||||
) -> Result<(), AppError> {
|
||||
let log = RequestLog {
|
||||
request_id,
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
first_token_ms: None,
|
||||
status_code,
|
||||
error_message: Some(error_message),
|
||||
session_id: None,
|
||||
provider_type: None,
|
||||
is_streaming: false,
|
||||
cost_multiplier: "1.0".to_string(),
|
||||
};
|
||||
|
||||
self.log_request(&log)
|
||||
}
|
||||
|
||||
/// 获取模型定价
|
||||
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
|
||||
let conn = crate::database::lock_conn!(self.db.conn);
|
||||
let row = find_model_pricing_row(&conn, model_id)?;
|
||||
match row {
|
||||
Some((input, output, cache_read, cache_creation)) => {
|
||||
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)
|
||||
.map(Some)
|
||||
.map_err(|e| AppError::Database(format!("解析定价数据失败: {e}")))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算并记录请求
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn log_with_calculation(
|
||||
&self,
|
||||
request_id: String,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
model: String,
|
||||
usage: TokenUsage,
|
||||
cost_multiplier: Decimal,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
status_code: u16,
|
||||
session_id: Option<String>,
|
||||
provider_type: Option<String>,
|
||||
is_streaming: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let pricing = self.get_model_pricing(&model)?;
|
||||
|
||||
if pricing.is_none() {
|
||||
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
|
||||
}
|
||||
|
||||
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||
|
||||
let log = RequestLog {
|
||||
request_id,
|
||||
provider_id,
|
||||
app_type,
|
||||
model,
|
||||
usage,
|
||||
cost,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
status_code,
|
||||
error_message: None,
|
||||
session_id,
|
||||
provider_type,
|
||||
is_streaming,
|
||||
cost_multiplier: cost_multiplier.to_string(),
|
||||
};
|
||||
|
||||
self.log_request(&log)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_log_request() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
// 插入测试定价
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES ('test-model', 'Test Model', '3.0', '15.0')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let logger = UsageLogger::new(&db);
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
};
|
||||
|
||||
logger.log_with_calculation(
|
||||
"req-123".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"claude".to_string(),
|
||||
"test-model".to_string(),
|
||||
usage,
|
||||
Decimal::from(1),
|
||||
100,
|
||||
None,
|
||||
200,
|
||||
None,
|
||||
Some("claude".to_string()),
|
||||
false,
|
||||
)?;
|
||||
|
||||
// 验证记录已插入
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_error() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let logger = UsageLogger::new(&db);
|
||||
|
||||
logger.log_error(
|
||||
"req-error".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"claude".to_string(),
|
||||
"unknown-model".to_string(),
|
||||
500,
|
||||
"Internal Server Error".to_string(),
|
||||
50,
|
||||
)?;
|
||||
|
||||
// 验证错误记录已插入
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (status, error): (i64, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT status_code, error_message FROM proxy_request_logs WHERE request_id = 'req-error'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, 500);
|
||||
assert_eq!(error, Some("Internal Server Error".to_string()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Proxy Usage Tracking Module
|
||||
//!
|
||||
//! 提供 API 请求的使用量跟踪、成本计算和日志记录功能
|
||||
|
||||
pub mod calculator;
|
||||
pub mod logger;
|
||||
pub mod parser;
|
||||
|
||||
// 仅导出内部使用的类型,避免未使用警告
|
||||
#[allow(unused_imports)]
|
||||
pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||
#[allow(unused_imports)]
|
||||
pub use logger::{RequestLog, UsageLogger};
|
||||
#[allow(unused_imports)]
|
||||
pub use parser::{ApiType, TokenUsage};
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Response Parser - 从 API 响应中提取 token 使用量
|
||||
//!
|
||||
//! 支持多种 API 格式:
|
||||
//! - Claude API (非流式和流式)
|
||||
//! - OpenRouter (OpenAI 格式)
|
||||
//! - Codex API (非流式和流式)
|
||||
//! - Gemini API (非流式和流式)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Token 使用量统计
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub cache_read_tokens: u32,
|
||||
pub cache_creation_tokens: u32,
|
||||
/// 从响应中提取的实际模型名称(如果可用)
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// API 类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ApiType {
|
||||
Claude,
|
||||
OpenRouter,
|
||||
Codex,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
/// 从 Claude API 非流式响应解析
|
||||
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
Some(Self {
|
||||
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
||||
output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
|
||||
cache_read_tokens: usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Claude API 流式响应解析
|
||||
#[allow(dead_code)]
|
||||
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
|
||||
let mut usage = Self::default();
|
||||
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
match event_type {
|
||||
"message_start" => {
|
||||
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
|
||||
// 从 message_start 获取 input_tokens(原生 Claude API)
|
||||
if let Some(input) =
|
||||
msg_usage.get("input_tokens").and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.input_tokens = input as u32;
|
||||
}
|
||||
usage.cache_read_tokens = msg_usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
as u32;
|
||||
usage.cache_creation_tokens = msg_usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
as u32;
|
||||
}
|
||||
}
|
||||
"message_delta" => {
|
||||
if let Some(delta_usage) = event.get("usage") {
|
||||
// 从 message_delta 获取 output_tokens
|
||||
if let Some(output) =
|
||||
delta_usage.get("output_tokens").and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.output_tokens = output as u32;
|
||||
}
|
||||
// OpenRouter 转换后的流式响应:input_tokens 也在 message_delta 中
|
||||
// 如果 message_start 中没有 input_tokens,则从 message_delta 获取
|
||||
if usage.input_tokens == 0 {
|
||||
if let Some(input) =
|
||||
delta_usage.get("input_tokens").and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.input_tokens = input as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||
Some(usage)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 OpenRouter 响应解析 (OpenAI 格式)
|
||||
#[allow(dead_code)]
|
||||
pub fn from_openrouter_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
Some(Self {
|
||||
input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32,
|
||||
output_tokens: usage.get("completion_tokens")?.as_u64()? as u32,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Codex API 非流式响应解析
|
||||
pub fn from_codex_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage");
|
||||
if usage.is_none() {
|
||||
log::debug!(
|
||||
"[Codex] 响应中没有 usage 字段,body keys: {:?}",
|
||||
body.as_object().map(|o| o.keys().collect::<Vec<_>>())
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let usage = usage?;
|
||||
|
||||
let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64());
|
||||
let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64());
|
||||
|
||||
if input_tokens.is_none() || output_tokens.is_none() {
|
||||
log::debug!("[Codex] usage 字段缺少 input_tokens 或 output_tokens,usage: {usage:?}");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
input_tokens: input_tokens? as u32,
|
||||
output_tokens: output_tokens? as u32,
|
||||
cache_read_tokens: usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Codex API 响应解析并调整 input_tokens
|
||||
///
|
||||
/// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数
|
||||
/// 公式: adjusted_input = max(input_tokens - cached_tokens, 0)
|
||||
#[allow(dead_code)]
|
||||
pub fn from_codex_response_adjusted(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
|
||||
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
|
||||
|
||||
// 获取 cached_tokens (可能在 input_tokens_details 中)
|
||||
let cached_tokens = usage
|
||||
.get("input_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 调整 input_tokens: 减去 cached_tokens
|
||||
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
|
||||
|
||||
Some(Self {
|
||||
input_tokens: adjusted_input,
|
||||
output_tokens,
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Codex API 流式响应解析
|
||||
#[allow(dead_code)]
|
||||
pub fn from_codex_stream_events(events: &[Value]) -> Option<Self> {
|
||||
log::debug!("[Codex] 解析流式事件,共 {} 个事件", events.len());
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
log::debug!("[Codex] 事件类型: {event_type}");
|
||||
if event_type == "response.completed" {
|
||||
if let Some(response) = event.get("response") {
|
||||
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
|
||||
return Self::from_codex_response(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log::debug!("[Codex] 未找到 response.completed 事件");
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)
|
||||
pub fn from_openai_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
|
||||
// OpenAI 使用 prompt_tokens 和 completion_tokens
|
||||
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64())?;
|
||||
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?;
|
||||
|
||||
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
|
||||
let cached_tokens = usage
|
||||
.get("prompt_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
Some(Self {
|
||||
input_tokens: prompt_tokens as u32,
|
||||
output_tokens: completion_tokens as u32,
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 OpenAI Chat Completions API 流式响应解析
|
||||
pub fn from_openai_stream_events(events: &[Value]) -> Option<Self> {
|
||||
log::debug!("[Codex] 解析 OpenAI 流式事件,共 {} 个事件", events.len());
|
||||
// OpenAI 流式响应在最后一个 chunk 中包含 usage
|
||||
for event in events.iter().rev() {
|
||||
if let Some(usage) = event.get("usage") {
|
||||
if !usage.is_null() {
|
||||
log::debug!("[Codex] 找到 usage: {usage:?}");
|
||||
return Self::from_openai_response(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::debug!("[Codex] 未找到 usage 信息");
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 Gemini API 非流式响应解析
|
||||
pub fn from_gemini_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usageMetadata")?;
|
||||
// 提取实际使用的模型名称(modelVersion 字段)
|
||||
let model = body
|
||||
.get("modelVersion")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
|
||||
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
|
||||
cache_read_tokens: usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 Gemini API 流式响应解析
|
||||
#[allow(dead_code)]
|
||||
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
|
||||
let mut total_input = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
let mut total_cache_read = 0u32;
|
||||
let mut model: Option<String> = None;
|
||||
|
||||
for chunk in chunks {
|
||||
if let Some(usage) = chunk.get("usageMetadata") {
|
||||
total_input = usage
|
||||
.get("promptTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
total_output += usage
|
||||
.get("candidatesTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
total_cache_read = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
}
|
||||
|
||||
// 提取实际使用的模型名称(modelVersion 字段)
|
||||
if model.is_none() {
|
||||
if let Some(model_version) = chunk.get("modelVersion").and_then(|v| v.as_str()) {
|
||||
model = Some(model_version.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total_input > 0 || total_output > 0 {
|
||||
Some(Self {
|
||||
input_tokens: total_input,
|
||||
output_tokens: total_output,
|
||||
cache_read_tokens: total_cache_read,
|
||||
cache_creation_tokens: 0,
|
||||
model,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_claude_response_parsing() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 20,
|
||||
"cache_creation_input_tokens": 10
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_claude_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_stream_parsing() {
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"cache_read_input_tokens": 20,
|
||||
"cache_creation_input_tokens": 10
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"output_tokens": 50
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openrouter_response_parsing() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_openrouter_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gemini_response_parsing() {
|
||||
let response = json!({
|
||||
"modelVersion": "gemini-3-pro-high",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 100,
|
||||
"candidatesTokenCount": 50,
|
||||
"cachedContentTokenCount": 20
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gemini_response_parsing_no_model() {
|
||||
// 测试没有 modelVersion 字段的情况
|
||||
let response = json!({
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 100,
|
||||
"candidatesTokenCount": 50,
|
||||
"cachedContentTokenCount": 20
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 300
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
// input_tokens 应该被调整: 1000 - 300 = 700
|
||||
assert_eq!(usage.input_tokens, 700);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_no_cache() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
// 没有 cached_tokens,input_tokens 保持不变
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_saturating_sub() {
|
||||
// 测试 cached_tokens > input_tokens 的边界情况
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 200
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
// saturating_sub 确保不会下溢
|
||||
assert_eq!(usage.input_tokens, 0);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openrouter_stream_parsing() {
|
||||
// 测试 OpenRouter 转换后的流式响应解析
|
||||
// OpenRouter 流式响应经过转换后,input_tokens 在 message_delta 中
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": "end_turn"
|
||||
},
|
||||
"usage": {
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 75
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 150);
|
||||
assert_eq!(usage.output_tokens, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_claude_stream_parsing() {
|
||||
// 测试原生 Claude API 流式响应解析
|
||||
// 原生 Claude API 的 input_tokens 在 message_start 中
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"usage": {
|
||||
"input_tokens": 200,
|
||||
"cache_read_input_tokens": 50
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"output_tokens": 100
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 200);
|
||||
assert_eq!(usage.output_tokens, 100);
|
||||
assert_eq!(usage.cache_read_tokens, 50);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,25 @@ pub mod config;
|
||||
pub mod env_checker;
|
||||
pub mod env_manager;
|
||||
pub mod mcp;
|
||||
pub mod model_test;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod usage_stats;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
#[allow(unused_imports)]
|
||||
pub use model_test::{ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService};
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use proxy::ProxyService;
|
||||
pub use skill::{Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
#[allow(unused_imports)]
|
||||
pub use usage_stats::{
|
||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||
RequestLogDetail, UsageSummary,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
//! 模型测试服务
|
||||
//!
|
||||
//! 提供独立的模型可用性测试功能,复用现有 Provider 适配器逻辑,
|
||||
//! 但不影响正常代理数据流程。测试结果记录到独立的日志表。
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, ProviderAdapter};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// 模型测试配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelTestConfig {
|
||||
/// 默认测试模型(Claude)
|
||||
pub claude_model: String,
|
||||
/// 默认测试模型(Codex/OpenAI)
|
||||
pub codex_model: String,
|
||||
/// 默认测试模型(Gemini)
|
||||
pub gemini_model: String,
|
||||
/// 测试提示词
|
||||
pub test_prompt: String,
|
||||
/// 超时时间(秒)
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for ModelTestConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||
codex_model: "gpt-5.1-low".to_string(),
|
||||
gemini_model: "gemini-3-pro-low".to_string(),
|
||||
test_prompt: "ping".to_string(),
|
||||
timeout_secs: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型测试结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelTestResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub response_time_ms: Option<u64>,
|
||||
pub http_status: Option<u16>,
|
||||
pub model_used: String,
|
||||
pub tested_at: i64,
|
||||
}
|
||||
|
||||
/// 模型测试日志记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelTestLog {
|
||||
pub id: i64,
|
||||
pub provider_id: String,
|
||||
pub provider_name: String,
|
||||
pub app_type: String,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub response_time_ms: Option<i64>,
|
||||
pub http_status: Option<i64>,
|
||||
pub tested_at: i64,
|
||||
}
|
||||
|
||||
/// 模型测试服务
|
||||
pub struct ModelTestService;
|
||||
|
||||
impl ModelTestService {
|
||||
/// 测试单个供应商的模型可用性
|
||||
pub async fn test_provider(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
config: &ModelTestConfig,
|
||||
) -> Result<ModelTestResult, AppError> {
|
||||
let start = Instant::now();
|
||||
let adapter = get_adapter(app_type);
|
||||
|
||||
// 构建 HTTP 客户端(独立于代理服务)
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))?;
|
||||
|
||||
// 根据 AppType 选择测试模型
|
||||
let model = match app_type {
|
||||
AppType::Claude => &config.claude_model,
|
||||
AppType::Codex => &config.codex_model,
|
||||
AppType::Gemini => &config.gemini_model,
|
||||
};
|
||||
|
||||
let result = match app_type {
|
||||
AppType::Claude => {
|
||||
Self::test_claude(
|
||||
&client,
|
||||
provider,
|
||||
adapter.as_ref(),
|
||||
model,
|
||||
&config.test_prompt,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::Codex => {
|
||||
Self::test_codex(
|
||||
&client,
|
||||
provider,
|
||||
adapter.as_ref(),
|
||||
model,
|
||||
&config.test_prompt,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AppType::Gemini => {
|
||||
Self::test_gemini(
|
||||
&client,
|
||||
provider,
|
||||
adapter.as_ref(),
|
||||
model,
|
||||
&config.test_prompt,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
let tested_at = chrono::Utc::now().timestamp();
|
||||
|
||||
match result {
|
||||
Ok((status, msg)) => Ok(ModelTestResult {
|
||||
success: true,
|
||||
message: msg,
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: Some(status),
|
||||
model_used: model.clone(),
|
||||
tested_at,
|
||||
}),
|
||||
Err(e) => Ok(ModelTestResult {
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: Some(response_time),
|
||||
http_status: None,
|
||||
model_used: model.clone(),
|
||||
tested_at,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 Claude (Anthropic Messages API)
|
||||
async fn test_claude(
|
||||
client: &Client,
|
||||
provider: &Provider,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||
|
||||
let auth = adapter
|
||||
.extract_auth(provider)
|
||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||
|
||||
// 智能拼接 URL,避免重复 /v1
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/messages")
|
||||
} else {
|
||||
format!("{base}/v1/messages")
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"max_tokens": 1,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}]
|
||||
});
|
||||
|
||||
let mut request = client.post(&url).json(&body);
|
||||
request = Self::add_claude_auth(request, &auth);
|
||||
|
||||
let response = request.send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
AppError::Message("请求超时".to_string())
|
||||
} else if e.is_connect() {
|
||||
AppError::Message(format!("连接失败: {e}"))
|
||||
} else {
|
||||
AppError::Message(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
|
||||
if response.status().is_success() {
|
||||
// 先获取文本,再尝试解析 JSON(兼容流式响应)
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
|
||||
// 尝试解析 JSON
|
||||
if let Ok(data) = serde_json::from_str::<Value>(&text) {
|
||||
if data.get("type").is_some()
|
||||
|| data.get("content").is_some()
|
||||
|| data.get("id").is_some()
|
||||
{
|
||||
return Ok((status, "模型测试成功".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 即使无法解析 JSON,只要状态码是 200 就认为成功
|
||||
Ok((status, "模型测试成功".to_string()))
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 Codex (OpenAI Chat Completions API)
|
||||
async fn test_codex(
|
||||
client: &Client,
|
||||
provider: &Provider,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||
|
||||
let auth = adapter
|
||||
.extract_auth(provider)
|
||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||
|
||||
// 智能拼接 URL,避免重复 /v1
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = if base.ends_with("/v1") {
|
||||
format!("{base}/chat/completions")
|
||||
} else {
|
||||
format!("{base}/v1/chat/completions")
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}],
|
||||
"max_tokens": 1,
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let request = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body);
|
||||
|
||||
let response = request.send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
AppError::Message("请求超时".to_string())
|
||||
} else if e.is_connect() {
|
||||
AppError::Message(format!("连接失败: {e}"))
|
||||
} else {
|
||||
AppError::Message(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
|
||||
if response.status().is_success() {
|
||||
// 先获取文本,再尝试解析 JSON
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
|
||||
if let Ok(data) = serde_json::from_str::<Value>(&text) {
|
||||
if data.get("choices").is_some() || data.get("id").is_some() {
|
||||
return Ok((status, "模型测试成功".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 即使无法解析 JSON,只要状态码是 200 就认为成功
|
||||
Ok((status, "模型测试成功".to_string()))
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 Gemini (Google Generative AI API)
|
||||
async fn test_gemini(
|
||||
client: &Client,
|
||||
provider: &Provider,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base_url = adapter
|
||||
.extract_base_url(provider)
|
||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||
|
||||
let auth = adapter
|
||||
.extract_auth(provider)
|
||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:generateContent?key={}",
|
||||
base_url.trim_end_matches('/'),
|
||||
model,
|
||||
auth.api_key
|
||||
);
|
||||
|
||||
let body = json!({
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": prompt
|
||||
}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 1
|
||||
}
|
||||
});
|
||||
|
||||
let request = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body);
|
||||
|
||||
let response = request.send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
AppError::Message("请求超时".to_string())
|
||||
} else if e.is_connect() {
|
||||
AppError::Message(format!("连接失败: {e}"))
|
||||
} else {
|
||||
AppError::Message(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
|
||||
if response.status().is_success() {
|
||||
let data: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Message(format!("解析响应失败: {e}")))?;
|
||||
|
||||
if data.get("candidates").is_some() {
|
||||
Ok((status, "模型测试成功".to_string()))
|
||||
} else {
|
||||
Err(AppError::Message("响应格式异常".to_string()))
|
||||
}
|
||||
} else {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加 Claude 认证头
|
||||
fn add_claude_auth(
|
||||
request: reqwest::RequestBuilder,
|
||||
auth: &AuthInfo,
|
||||
) -> reqwest::RequestBuilder {
|
||||
request
|
||||
.header("x-api-key", &auth.api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 数据库操作 =====
|
||||
|
||||
impl Database {
|
||||
/// 保存模型测试日志
|
||||
pub fn save_model_test_log(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
provider_name: &str,
|
||||
app_type: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
result: &ModelTestResult,
|
||||
) -> Result<i64, AppError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO model_test_logs
|
||||
(provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
rusqlite::params![
|
||||
provider_id,
|
||||
provider_name,
|
||||
app_type,
|
||||
model,
|
||||
prompt,
|
||||
result.success,
|
||||
result.message,
|
||||
result.response_time_ms.map(|t| t as i64),
|
||||
result.http_status.map(|s| s as i64),
|
||||
result.tested_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// 获取模型测试日志
|
||||
pub fn get_model_test_logs(
|
||||
&self,
|
||||
app_type: Option<&str>,
|
||||
provider_id: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ModelTestLog>, AppError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
|
||||
|
||||
let mut sql = String::from(
|
||||
"SELECT id, provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at
|
||||
FROM model_test_logs WHERE 1=1"
|
||||
);
|
||||
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(at) = app_type {
|
||||
sql.push_str(" AND app_type = ?");
|
||||
params.push(Box::new(at.to_string()));
|
||||
}
|
||||
|
||||
if let Some(pid) = provider_id {
|
||||
sql.push_str(" AND provider_id = ?");
|
||||
params.push(Box::new(pid.to_string()));
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY tested_at DESC LIMIT ?");
|
||||
params.push(Box::new(limit as i64));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let logs = stmt
|
||||
.query_map(params_refs.as_slice(), |row| {
|
||||
Ok(ModelTestLog {
|
||||
id: row.get(0)?,
|
||||
provider_id: row.get(1)?,
|
||||
provider_name: row.get(2)?,
|
||||
app_type: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
prompt: row.get(5)?,
|
||||
success: row.get(6)?,
|
||||
message: row.get(7)?,
|
||||
response_time_ms: row.get(8)?,
|
||||
http_status: row.get(9)?,
|
||||
tested_at: row.get(10)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(logs)
|
||||
}
|
||||
|
||||
/// 获取模型测试配置
|
||||
pub fn get_model_test_config(&self) -> Result<ModelTestConfig, AppError> {
|
||||
match self.get_setting("model_test_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Message(format!("解析模型测试配置失败: {e}"))),
|
||||
None => Ok(ModelTestConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存模型测试配置
|
||||
pub fn save_model_test_config(&self, config: &ModelTestConfig) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Message(format!("序列化模型测试配置失败: {e}")))?;
|
||||
self.set_setting("model_test_config", &json)
|
||||
}
|
||||
|
||||
/// 清理旧的测试日志(保留最近 N 条)
|
||||
pub fn cleanup_model_test_logs(&self, keep_count: u32) -> Result<u64, AppError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
|
||||
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM model_test_logs WHERE id NOT IN (
|
||||
SELECT id FROM model_test_logs ORDER BY tested_at DESC LIMIT ?
|
||||
)",
|
||||
rusqlite::params![keep_count as i64],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,12 @@ impl PromptService {
|
||||
state: &AppState,
|
||||
app: AppType,
|
||||
) -> Result<usize, AppError> {
|
||||
// 幂等性保护:该应用已有提示词则跳过
|
||||
let existing = state.db.get_prompts(app.as_str())?;
|
||||
if !existing.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let file_path = prompt_file_path(&app)?;
|
||||
|
||||
// 检查文件是否存在
|
||||
|
||||
@@ -100,12 +100,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
write_json_file(&path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let obj = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string())
|
||||
})?;
|
||||
let auth = obj.get("auth").ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string())
|
||||
})?;
|
||||
let obj = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||
let auth = obj
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
|
||||
})?;
|
||||
@@ -113,8 +114,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
let auth_path = get_codex_auth_path();
|
||||
write_json_file(&auth_path, auth)?;
|
||||
let config_path = get_codex_config_path();
|
||||
std::fs::write(&config_path, config_str)
|
||||
.map_err(|e| AppError::io(&config_path, e))?;
|
||||
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
|
||||
}
|
||||
AppType::Gemini => {
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
@@ -132,11 +132,11 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
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 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) {
|
||||
@@ -215,11 +215,14 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
/// Import default configuration from live files
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||
///
|
||||
/// Returns `Ok(true)` if a provider was actually imported,
|
||||
/// `Ok(false)` if skipped (providers already exist for this app).
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
{
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if !providers.is_empty() {
|
||||
return Ok(());
|
||||
return Ok(false); // 已有供应商,跳过
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +301,7 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(),
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
|
||||
Ok(())
|
||||
Ok(true) // 真正导入了
|
||||
}
|
||||
|
||||
/// Write Gemini live configuration with authentication handling
|
||||
|
||||
@@ -13,8 +13,6 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::write_codex_live_atomic;
|
||||
use crate::config::{get_claude_settings_path, write_json_file};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, UsageResult};
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -219,13 +217,27 @@ impl ProviderService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set proxy target provider
|
||||
pub fn set_proxy_target(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// Check if provider exists
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if !providers.contains_key(id) {
|
||||
return Err(AppError::Message(format!("供应商 {id} 不存在")));
|
||||
}
|
||||
|
||||
state.db.set_proxy_target_provider(app_type.as_str(), id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider to live configuration (re-export)
|
||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
sync_current_to_live(state)
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||
///
|
||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||
import_default_config(state, app_type)
|
||||
}
|
||||
|
||||
@@ -327,36 +339,6 @@ impl ProviderService {
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn write_codex_live(provider: &Provider) -> Result<(), AppError> {
|
||||
let settings = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
|
||||
let auth = settings
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?;
|
||||
if !auth.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
"供应商 {} 的 auth 必须是对象",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
write_codex_live_atomic(auth, cfg_text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
|
||||
let settings_path = get_claude_settings_path();
|
||||
let mut content = provider.settings_config.clone();
|
||||
let _ = normalize_claude_models_in_value(&mut content);
|
||||
write_json_file(&settings_path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
write_gemini_live(provider)
|
||||
}
|
||||
@@ -555,15 +537,6 @@ impl ProviderService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn app_not_found(app_type: &AppType) -> AppError {
|
||||
AppError::localized(
|
||||
"provider.app_not_found",
|
||||
format!("应用类型不存在: {app_type:?}"),
|
||||
format!("App type not found: {app_type:?}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize Claude model keys in a JSON value
|
||||
|
||||
@@ -168,9 +168,7 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
|
||||
if interval > 1440 {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.interval_too_large",
|
||||
format!(
|
||||
"自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
|
||||
),
|
||||
format!("自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"),
|
||||
format!(
|
||||
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
|
||||
),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 代理服务业务逻辑层
|
||||
//!
|
||||
//! 提供代理服务器的启动、停止和配置管理
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::proxy::server::ProxyServer;
|
||||
use crate::proxy::types::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyService {
|
||||
db: Arc<Database>,
|
||||
server: Arc<RwLock<Option<ProxyServer>>>,
|
||||
}
|
||||
|
||||
impl ProxyService {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
server: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动代理服务器
|
||||
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
|
||||
// 1. 获取配置
|
||||
let mut config = self
|
||||
.db
|
||||
.get_proxy_config()
|
||||
.await
|
||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
||||
|
||||
// 2. 确保配置启用(用户通过UI启动即表示希望启用)
|
||||
config.enabled = true;
|
||||
|
||||
// 3. 检查是否已在运行
|
||||
if self.server.read().await.is_some() {
|
||||
return Err("代理服务已在运行中".to_string());
|
||||
}
|
||||
|
||||
// 4. 创建并启动服务器
|
||||
let server = ProxyServer::new(config.clone(), self.db.clone());
|
||||
let info = server
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e| format!("启动代理服务器失败: {e}"))?;
|
||||
|
||||
// 5. 保存服务器实例
|
||||
*self.server.write().await = Some(server);
|
||||
|
||||
// 6. 持久化 enabled 状态
|
||||
self.db
|
||||
.update_proxy_config(config)
|
||||
.await
|
||||
.map_err(|e| format!("保存代理配置失败: {e}"))?;
|
||||
|
||||
log::info!("代理服务器已启动: {}:{}", info.address, info.port);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// 停止代理服务器
|
||||
pub async fn stop(&self) -> Result<(), String> {
|
||||
if let Some(server) = self.server.write().await.take() {
|
||||
server
|
||||
.stop()
|
||||
.await
|
||||
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
|
||||
log::info!("代理服务器已停止");
|
||||
Ok(())
|
||||
} else {
|
||||
Err("代理服务器未运行".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取服务器状态
|
||||
pub async fn get_status(&self) -> Result<ProxyStatus, String> {
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
Ok(server.get_status().await)
|
||||
} else {
|
||||
// 服务器未运行时返回默认状态
|
||||
Ok(ProxyStatus {
|
||||
running: false,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取代理配置
|
||||
pub async fn get_config(&self) -> Result<ProxyConfig, String> {
|
||||
self.db
|
||||
.get_proxy_config()
|
||||
.await
|
||||
.map_err(|e| format!("获取代理配置失败: {e}"))
|
||||
}
|
||||
|
||||
/// 更新代理配置
|
||||
pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {
|
||||
// 记录旧配置用于判定是否需要重启
|
||||
let previous = self
|
||||
.db
|
||||
.get_proxy_config()
|
||||
.await
|
||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
||||
|
||||
// 保存到数据库(保持 enabled 状态不变)
|
||||
let mut new_config = config.clone();
|
||||
new_config.enabled = previous.enabled;
|
||||
|
||||
self.db
|
||||
.update_proxy_config(new_config.clone())
|
||||
.await
|
||||
.map_err(|e| format!("保存代理配置失败: {e}"))?;
|
||||
|
||||
// 检查服务器当前状态
|
||||
let mut server_guard = self.server.write().await;
|
||||
if server_guard.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 判断是否需要重启(地址或端口变更)
|
||||
let require_restart = new_config.listen_address != previous.listen_address
|
||||
|| new_config.listen_port != previous.listen_port;
|
||||
|
||||
if require_restart {
|
||||
if let Some(server) = server_guard.take() {
|
||||
server
|
||||
.stop()
|
||||
.await
|
||||
.map_err(|e| format!("重启前停止代理服务器失败: {e}"))?;
|
||||
}
|
||||
|
||||
let new_server = ProxyServer::new(new_config, self.db.clone());
|
||||
new_server
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e| format!("重启代理服务器失败: {e}"))?;
|
||||
|
||||
*server_guard = Some(new_server);
|
||||
log::info!("代理配置已更新,服务器已自动重启应用最新配置");
|
||||
} else if let Some(server) = server_guard.as_ref() {
|
||||
server.apply_runtime_config(&new_config).await;
|
||||
log::info!("代理配置已实时应用,无需重启代理服务器");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查服务器是否正在运行
|
||||
pub async fn is_running(&self) -> bool {
|
||||
self.server.read().await.is_some()
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,6 @@ pub struct Skill {
|
||||
/// 分支名称
|
||||
#[serde(rename = "repoBranch")]
|
||||
pub repo_branch: Option<String>,
|
||||
/// 技能所在的子目录路径 (可选, 如 "skills")
|
||||
#[serde(rename = "skillsPath")]
|
||||
pub skills_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 仓库配置
|
||||
@@ -50,9 +47,6 @@ pub struct SkillRepo {
|
||||
pub branch: String,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
/// 技能所在的子目录路径 (可选, 如 "skills", "my-skills/subdir")
|
||||
#[serde(rename = "skillsPath")]
|
||||
pub skills_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 技能安装状态
|
||||
@@ -84,21 +78,18 @@ impl Default for SkillStore {
|
||||
name: "awesome-claude-skills".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
skills_path: None, // 扫描根目录
|
||||
},
|
||||
SkillRepo {
|
||||
owner: "anthropics".to_string(),
|
||||
name: "skills".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
skills_path: None, // 扫描根目录
|
||||
},
|
||||
SkillRepo {
|
||||
owner: "cexll".to_string(),
|
||||
name: "myclaude".to_string(),
|
||||
branch: "master".to_string(),
|
||||
enabled: true,
|
||||
skills_path: Some("skills".to_string()), // 扫描 skills 子目录
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -194,76 +185,11 @@ impl SkillService {
|
||||
})??;
|
||||
let mut skills = Vec::new();
|
||||
|
||||
// 确定要扫描的目录路径
|
||||
let scan_dir = if let Some(ref skills_path) = repo.skills_path {
|
||||
// 如果指定了 skillsPath,则扫描该子目录
|
||||
let subdir = temp_dir.join(skills_path.trim_matches('/'));
|
||||
if !subdir.exists() {
|
||||
log::warn!(
|
||||
"仓库 {}/{} 中指定的技能路径 '{}' 不存在",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
skills_path
|
||||
);
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Ok(skills);
|
||||
}
|
||||
subdir
|
||||
} else {
|
||||
// 否则扫描仓库根目录
|
||||
temp_dir.clone()
|
||||
};
|
||||
// 扫描仓库根目录(支持全仓库递归扫描)
|
||||
let scan_dir = temp_dir.clone();
|
||||
|
||||
// 遍历目标目录
|
||||
for entry in fs::read_dir(&scan_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if !skill_md.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析技能元数据
|
||||
match self.parse_skill_metadata(&skill_md) {
|
||||
Ok(meta) => {
|
||||
// 安全地获取目录名
|
||||
let Some(dir_name) = path.file_name() else {
|
||||
log::warn!("Failed to get directory name from path: {path:?}");
|
||||
continue;
|
||||
};
|
||||
let directory = dir_name.to_string_lossy().to_string();
|
||||
|
||||
// 构建 README URL(考虑 skillsPath)
|
||||
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||
format!("{}/{}", skills_path.trim_matches('/'), directory)
|
||||
} else {
|
||||
directory.clone()
|
||||
};
|
||||
|
||||
skills.push(Skill {
|
||||
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory,
|
||||
readme_url: Some(format!(
|
||||
"https://github.com/{}/{}/tree/{}/{}",
|
||||
repo.owner, repo.name, repo.branch, readme_path
|
||||
)),
|
||||
installed: false,
|
||||
repo_owner: Some(repo.owner.clone()),
|
||||
repo_name: Some(repo.name.clone()),
|
||||
repo_branch: Some(repo.branch.clone()),
|
||||
skills_path: repo.skills_path.clone(),
|
||||
});
|
||||
}
|
||||
Err(e) => log::warn!("解析 {} 元数据失败: {}", skill_md.display(), e),
|
||||
}
|
||||
}
|
||||
// 递归扫描目录查找所有技能
|
||||
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
|
||||
|
||||
// 清理临时目录
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
@@ -271,6 +197,85 @@ impl SkillService {
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
/// 递归扫描目录查找 SKILL.md
|
||||
///
|
||||
/// 规则:
|
||||
/// 1. 如果当前目录存在 SKILL.md,则识别为技能,停止扫描其子目录(子目录视为功能文件夹)
|
||||
/// 2. 如果当前目录不存在 SKILL.md,则递归扫描所有子目录
|
||||
fn scan_dir_recursive(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
base_dir: &Path,
|
||||
repo: &SkillRepo,
|
||||
skills: &mut Vec<Skill>,
|
||||
) -> Result<()> {
|
||||
// 检查当前目录是否包含 SKILL.md
|
||||
let skill_md = current_dir.join("SKILL.md");
|
||||
|
||||
if skill_md.exists() {
|
||||
// 发现技能!获取相对路径作为目录名
|
||||
let directory = if current_dir == base_dir {
|
||||
// 根目录的 SKILL.md,使用仓库名
|
||||
repo.name.clone()
|
||||
} else {
|
||||
// 子目录的 SKILL.md,使用相对路径
|
||||
current_dir
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(current_dir)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
|
||||
skills.push(skill);
|
||||
}
|
||||
|
||||
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 未发现 SKILL.md,继续递归扫描所有子目录
|
||||
for entry in fs::read_dir(current_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// 只处理目录
|
||||
if path.is_dir() {
|
||||
self.scan_dir_recursive(&path, base_dir, repo, skills)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 SKILL.md 构建技能对象
|
||||
fn build_skill_from_metadata(
|
||||
&self,
|
||||
skill_md: &Path,
|
||||
directory: &str,
|
||||
repo: &SkillRepo,
|
||||
) -> Result<Skill> {
|
||||
let meta = self.parse_skill_metadata(skill_md)?;
|
||||
|
||||
// 构建 README URL
|
||||
let readme_path = directory.to_string();
|
||||
|
||||
Ok(Skill {
|
||||
key: format!("{}/{}:{}", repo.owner, repo.name, directory),
|
||||
name: meta.name.unwrap_or_else(|| directory.to_string()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory: directory.to_string(),
|
||||
readme_url: Some(format!(
|
||||
"https://github.com/{}/{}/tree/{}/{}",
|
||||
repo.owner, repo.name, repo.branch, readme_path
|
||||
)),
|
||||
installed: false,
|
||||
repo_owner: Some(repo.owner.clone()),
|
||||
repo_name: Some(repo.name.clone()),
|
||||
repo_branch: Some(repo.branch.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析技能元数据
|
||||
fn parse_skill_metadata(&self, path: &Path) -> Result<SkillMetadata> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
@@ -302,25 +307,18 @@ impl SkillService {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&self.install_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
// 收集所有本地技能
|
||||
let mut local_skills = Vec::new();
|
||||
self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?;
|
||||
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// 处理找到的本地技能
|
||||
for local_skill in local_skills {
|
||||
let directory = &local_skill.directory;
|
||||
|
||||
// 安全地获取目录名
|
||||
let Some(dir_name) = path.file_name() else {
|
||||
log::warn!("Failed to get directory name from path: {path:?}");
|
||||
continue;
|
||||
};
|
||||
let directory = dir_name.to_string_lossy().to_string();
|
||||
|
||||
// 更新已安装状态
|
||||
// 更新已安装状态(匹配远程技能)
|
||||
let mut found = false;
|
||||
for skill in skills.iter_mut() {
|
||||
if skill.directory.eq_ignore_ascii_case(&directory) {
|
||||
if skill.directory.eq_ignore_ascii_case(directory) {
|
||||
skill.installed = true;
|
||||
found = true;
|
||||
break;
|
||||
@@ -329,23 +327,68 @@ impl SkillService {
|
||||
|
||||
// 添加本地独有的技能(仅当在仓库中未找到时)
|
||||
if !found {
|
||||
let skill_md = path.join("SKILL.md");
|
||||
if skill_md.exists() {
|
||||
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
|
||||
skills.push(Skill {
|
||||
key: format!("local:{directory}"),
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory: directory.clone(),
|
||||
readme_url: None,
|
||||
installed: true,
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
skills_path: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
skills.push(local_skill);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归扫描本地目录查找 SKILL.md
|
||||
fn scan_local_dir_recursive(
|
||||
&self,
|
||||
current_dir: &Path,
|
||||
base_dir: &Path,
|
||||
skills: &mut Vec<Skill>,
|
||||
) -> Result<()> {
|
||||
// 检查当前目录是否包含 SKILL.md
|
||||
let skill_md = current_dir.join("SKILL.md");
|
||||
|
||||
if skill_md.exists() {
|
||||
// 发现技能!获取相对路径作为目录名
|
||||
let directory = if current_dir == base_dir {
|
||||
// 如果是 install_dir 本身,使用最后一段路径名
|
||||
current_dir
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
} else {
|
||||
// 使用相对于 install_dir 的路径
|
||||
current_dir
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(current_dir)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// 解析元数据并创建本地技能对象
|
||||
if let Ok(meta) = self.parse_skill_metadata(&skill_md) {
|
||||
skills.push(Skill {
|
||||
key: format!("local:{directory}"),
|
||||
name: meta.name.unwrap_or_else(|| directory.clone()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory: directory.clone(),
|
||||
readme_url: None,
|
||||
installed: true,
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 停止扫描此目录的子目录(同级目录都是功能文件夹)
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 未发现 SKILL.md,继续递归扫描所有子目录
|
||||
for entry in fs::read_dir(current_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
// 只处理目录
|
||||
if path.is_dir() {
|
||||
self.scan_local_dir_recursive(&path, base_dir, skills)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,16 +542,8 @@ impl SkillService {
|
||||
))
|
||||
})??;
|
||||
|
||||
// 根据 skills_path 确定源目录路径
|
||||
let source = if let Some(ref skills_path) = repo.skills_path {
|
||||
// 如果指定了 skills_path,源路径为: temp_dir/skills_path/directory
|
||||
temp_dir
|
||||
.join(skills_path.trim_matches('/'))
|
||||
.join(&directory)
|
||||
} else {
|
||||
// 否则源路径为: temp_dir/directory
|
||||
temp_dir.join(&directory)
|
||||
};
|
||||
// 确定源目录路径(技能相对于仓库根目录的路径)
|
||||
let source = temp_dir.join(&directory);
|
||||
|
||||
if !source.exists() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -118,7 +118,7 @@ impl AppSettings {
|
||||
.language
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| matches!(*s, "en" | "zh"))
|
||||
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use crate::database::Database;
|
||||
use crate::services::ProxyService;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 全局应用状态
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub proxy_service: ProxyService,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// 创建新的应用状态
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
let proxy_service = ProxyService::new(db.clone());
|
||||
|
||||
Self { db, proxy_service }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! 托盘菜单管理模块
|
||||
//!
|
||||
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
|
||||
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 托盘菜单文本(国际化)
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TrayTexts {
|
||||
pub show_main: &'static str,
|
||||
pub no_provider_hint: &'static str,
|
||||
pub quit: &'static str,
|
||||
}
|
||||
|
||||
impl TrayTexts {
|
||||
pub fn from_language(language: &str) -> Self {
|
||||
match language {
|
||||
"en" => Self {
|
||||
show_main: "Open main window",
|
||||
no_provider_hint: " (No providers yet, please add them from the main window)",
|
||||
quit: "Quit",
|
||||
},
|
||||
"ja" => Self {
|
||||
show_main: "メインウィンドウを開く",
|
||||
no_provider_hint:
|
||||
" (プロバイダーがまだありません。メイン画面から追加してください)",
|
||||
quit: "終了",
|
||||
},
|
||||
_ => Self {
|
||||
show_main: "打开主界面",
|
||||
no_provider_hint: " (无供应商,请在主界面添加)",
|
||||
quit: "退出",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 托盘应用分区配置
|
||||
pub struct TrayAppSection {
|
||||
pub app_type: AppType,
|
||||
pub prefix: &'static str,
|
||||
pub header_id: &'static str,
|
||||
pub empty_id: &'static str,
|
||||
pub header_label: &'static str,
|
||||
pub log_name: &'static str,
|
||||
}
|
||||
|
||||
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
TrayAppSection {
|
||||
app_type: AppType::Claude,
|
||||
prefix: "claude_",
|
||||
header_id: "claude_header",
|
||||
empty_id: "claude_empty",
|
||||
header_label: "─── Claude ───",
|
||||
log_name: "Claude",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Codex,
|
||||
prefix: "codex_",
|
||||
header_id: "codex_header",
|
||||
empty_id: "codex_empty",
|
||||
header_label: "─── Codex ───",
|
||||
log_name: "Codex",
|
||||
},
|
||||
TrayAppSection {
|
||||
app_type: AppType::Gemini,
|
||||
prefix: "gemini_",
|
||||
header_id: "gemini_header",
|
||||
empty_id: "gemini_empty",
|
||||
header_label: "─── Gemini ───",
|
||||
log_name: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
/// 添加供应商分区到菜单
|
||||
fn append_provider_section<'a>(
|
||||
app: &'a tauri::AppHandle,
|
||||
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
|
||||
manager: Option<&crate::provider::ProviderManager>,
|
||||
section: &TrayAppSection,
|
||||
tray_texts: &TrayTexts,
|
||||
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
|
||||
let Some(manager) = manager else {
|
||||
return Ok(menu_builder);
|
||||
};
|
||||
|
||||
let header = MenuItem::with_id(
|
||||
app,
|
||||
section.header_id,
|
||||
section.header_label,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&header);
|
||||
|
||||
if manager.providers.is_empty() {
|
||||
let empty_hint = MenuItem::with_id(
|
||||
app,
|
||||
section.empty_id,
|
||||
tray_texts.no_provider_hint,
|
||||
false,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
|
||||
return Ok(menu_builder.item(&empty_hint));
|
||||
}
|
||||
|
||||
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
|
||||
sorted_providers.sort_by(|(_, a), (_, b)| {
|
||||
match (a.sort_index, b.sort_index) {
|
||||
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Greater,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match (a.created_at, b.created_at) {
|
||||
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
a.name.cmp(&b.name)
|
||||
});
|
||||
|
||||
for (id, provider) in sorted_providers {
|
||||
let is_current = manager.current == *id;
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
|
||||
Ok(menu_builder)
|
||||
}
|
||||
|
||||
/// 处理供应商托盘事件
|
||||
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
|
||||
log::info!("切换到{}供应商: {provider_id}", section.log_name);
|
||||
let app_handle = app.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
let app_type = section.app_type.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
|
||||
log::error!("切换{}供应商失败: {e}", section.log_name);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 创建动态托盘菜单
|
||||
pub fn create_tray_menu(
|
||||
app: &tauri::AppHandle,
|
||||
app_state: &AppState,
|
||||
) -> Result<Menu<tauri::Wry>, AppError> {
|
||||
let app_settings = crate::settings::get_settings();
|
||||
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
|
||||
// 顶部:打开主界面
|
||||
let show_main_item =
|
||||
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
let app_type_str = section.app_type.as_str();
|
||||
let providers = app_state.db.get_all_providers(app_type_str)?;
|
||||
|
||||
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
|
||||
let current_id =
|
||||
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let manager = crate::provider::ProviderManager {
|
||||
providers,
|
||||
current: current_id,
|
||||
};
|
||||
|
||||
menu_builder =
|
||||
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
|
||||
}
|
||||
|
||||
// 分隔符和退出菜单
|
||||
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
|
||||
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
|
||||
|
||||
menu_builder = menu_builder.separator().item(&quit_item);
|
||||
|
||||
menu_builder
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||
use tauri::ActivationPolicy;
|
||||
|
||||
let desired_policy = if dock_visible {
|
||||
ActivationPolicy::Regular
|
||||
} else {
|
||||
ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
||||
log::warn!("设置 Dock 显示状态失败: {err}");
|
||||
}
|
||||
|
||||
if let Err(err) = app.set_activation_policy(desired_policy) {
|
||||
log::warn!("设置激活策略失败: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理托盘菜单事件
|
||||
pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
log::info!("处理托盘菜单事件: {event_id}");
|
||||
|
||||
match event_id {
|
||||
"show_main" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(app, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
log::info!("退出应用");
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {
|
||||
if handle_provider_tray_event(app, event_id) {
|
||||
return;
|
||||
}
|
||||
log::warn!("未处理的菜单事件: {event_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部切换供应商函数
|
||||
pub fn switch_provider_internal(
|
||||
app: &tauri::AppHandle,
|
||||
app_type: AppType,
|
||||
provider_id: String,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(app_state) = app.try_state::<AppState>() {
|
||||
// 在使用前先保存需要的值
|
||||
let app_type_str = app_type.as_str().to_string();
|
||||
let provider_id_clone = provider_id.clone();
|
||||
|
||||
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
|
||||
.map_err(AppError::Message)?;
|
||||
|
||||
// 切换成功后重新创建托盘菜单
|
||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
log::error!("更新托盘菜单失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发射事件到前端,通知供应商已切换
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"providerId": provider_id_clone
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
log::error!("发射供应商切换事件失败: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.7.1",
|
||||
"version": "3.8.2",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -3,13 +3,15 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cc_switch_lib::{
|
||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService,
|
||||
MultiAppConfig, Provider, ProviderMeta,
|
||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
|
||||
Provider, ProviderMeta,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||||
use support::{
|
||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn sync_claude_provider_writes_live_settings() {
|
||||
|
||||
@@ -42,9 +42,13 @@ fn import_default_config_claude_persists_provider() {
|
||||
.expect("import default config succeeds");
|
||||
|
||||
// 验证内存状态
|
||||
let providers = state.db.get_all_providers(AppType::Claude.as_str())
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.expect("get all providers");
|
||||
let current_id = state.db.get_current_provider(AppType::Claude.as_str())
|
||||
let current_id = state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider");
|
||||
assert_eq!(current_id.as_deref(), Some("default"));
|
||||
let default_provider = providers.get("default").expect("default provider");
|
||||
@@ -87,7 +91,9 @@ fn import_default_config_without_live_file_returns_error() {
|
||||
|
||||
// 使用数据库架构,不再检查 config.json
|
||||
// 失败的导入不应该向数据库写入任何供应商
|
||||
let providers = state.db.get_all_providers(AppType::Claude.as_str())
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.expect("get all providers");
|
||||
assert!(
|
||||
providers.is_empty(),
|
||||
@@ -125,8 +131,7 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
|
||||
"import should report inserted or normalized entries"
|
||||
);
|
||||
|
||||
let servers = state.db.get_all_mcp_servers()
|
||||
.expect("get all mcp servers");
|
||||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||||
let entry = servers
|
||||
.get("echo")
|
||||
.expect("server imported into unified structure");
|
||||
@@ -168,8 +173,7 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
|
||||
}
|
||||
|
||||
// 使用数据库架构,检查 MCP 服务器未被写入
|
||||
let servers = state.db.get_all_mcp_servers()
|
||||
.expect("get all mcp servers");
|
||||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||||
assert!(
|
||||
servers.is_empty(),
|
||||
"failed import should not persist any MCP servers to database"
|
||||
@@ -224,11 +228,8 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
|
||||
.expect("toggle_app should succeed");
|
||||
|
||||
let servers = state.db.get_all_mcp_servers()
|
||||
.expect("get all mcp servers");
|
||||
let entry = servers
|
||||
.get("codex-server")
|
||||
.expect("codex server exists");
|
||||
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
|
||||
let entry = servers.get("codex-server").expect("codex server exists");
|
||||
assert!(
|
||||
entry.apps.codex,
|
||||
"server should have Codex app enabled after toggle"
|
||||
|
||||
@@ -7,8 +7,8 @@ use cc_switch_lib::{
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||||
use std::collections::HashMap;
|
||||
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||||
|
||||
#[test]
|
||||
fn switch_provider_updates_codex_live_and_state() {
|
||||
@@ -104,16 +104,22 @@ command = "say"
|
||||
"config.toml should contain synced MCP servers"
|
||||
);
|
||||
|
||||
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
|
||||
let current_id = app_state
|
||||
.db
|
||||
.get_current_provider(AppType::Codex.as_str())
|
||||
.expect("get current provider");
|
||||
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
|
||||
assert_eq!(
|
||||
current_id.as_deref(),
|
||||
Some("new-provider"),
|
||||
"current provider updated"
|
||||
);
|
||||
|
||||
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
|
||||
let providers = app_state
|
||||
.db
|
||||
.get_all_providers(AppType::Codex.as_str())
|
||||
.expect("get all providers");
|
||||
|
||||
let new_provider = providers
|
||||
.get("new-provider")
|
||||
.expect("new provider exists");
|
||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||
let new_config_text = new_provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
@@ -165,7 +171,9 @@ fn switch_provider_missing_provider_returns_error() {
|
||||
|
||||
let err_str = err.to_string();
|
||||
assert!(
|
||||
err_str.contains("供应商不存在") || err_str.contains("Provider not found") || err_str.contains("missing-provider"),
|
||||
err_str.contains("供应商不存在")
|
||||
|| err_str.contains("Provider not found")
|
||||
|| err_str.contains("missing-provider"),
|
||||
"error message should mention missing provider, got: {err_str}"
|
||||
);
|
||||
}
|
||||
@@ -241,11 +249,19 @@ fn switch_provider_updates_claude_live_and_state() {
|
||||
"live settings.json should reflect new provider auth"
|
||||
);
|
||||
|
||||
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
|
||||
let current_id = app_state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider");
|
||||
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
|
||||
assert_eq!(
|
||||
current_id.as_deref(),
|
||||
Some("new-provider"),
|
||||
"current provider updated"
|
||||
);
|
||||
|
||||
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
|
||||
let providers = app_state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.expect("get all providers");
|
||||
|
||||
let legacy_provider = providers
|
||||
@@ -258,9 +274,7 @@ fn switch_provider_updates_claude_live_and_state() {
|
||||
"previous provider should be backfilled with live config"
|
||||
);
|
||||
|
||||
let new_provider = providers
|
||||
.get("new-provider")
|
||||
.expect("new provider exists");
|
||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||
assert_eq!(
|
||||
new_provider
|
||||
.settings_config
|
||||
@@ -283,7 +297,9 @@ fn switch_provider_updates_claude_live_and_state() {
|
||||
);
|
||||
|
||||
// 验证当前供应商已更新
|
||||
let current_id = app_state.db.get_current_provider(AppType::Claude.as_str())
|
||||
let current_id = app_state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider");
|
||||
assert_eq!(
|
||||
current_id.as_deref(),
|
||||
@@ -328,7 +344,9 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
|
||||
other => panic!("expected config error, got {other:?}"),
|
||||
}
|
||||
|
||||
let current_id = app_state.db.get_current_provider(AppType::Codex.as_str())
|
||||
let current_id = app_state
|
||||
.db
|
||||
.get_current_provider(AppType::Codex.as_str())
|
||||
.expect("get current provider");
|
||||
// 切换失败后,由于数据库操作是先设置再验证,current 可能已被设为 "invalid"
|
||||
// 但由于 live 配置写入失败,状态应该回滚
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use serde_json::json;
|
||||
|
||||
use cc_switch_lib::{
|
||||
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType,
|
||||
McpApps, McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
|
||||
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
|
||||
McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
||||
use support::{
|
||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||
};
|
||||
|
||||
fn sanitize_provider_name(name: &str) -> String {
|
||||
name.chars()
|
||||
@@ -69,7 +71,10 @@ command = "say"
|
||||
}
|
||||
|
||||
// 使用新的统一 MCP 结构(v3.7.0+)
|
||||
let servers = initial_config.mcp.servers.get_or_insert_with(Default::default);
|
||||
let servers = initial_config
|
||||
.mcp
|
||||
.servers
|
||||
.get_or_insert_with(Default::default);
|
||||
servers.insert(
|
||||
"echo-server".into(),
|
||||
McpServer {
|
||||
@@ -111,16 +116,22 @@ command = "say"
|
||||
"config.toml should contain synced MCP servers"
|
||||
);
|
||||
|
||||
let current_id = state.db.get_current_provider(AppType::Codex.as_str())
|
||||
let current_id = state
|
||||
.db
|
||||
.get_current_provider(AppType::Codex.as_str())
|
||||
.expect("read current provider after switch");
|
||||
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
|
||||
assert_eq!(
|
||||
current_id.as_deref(),
|
||||
Some("new-provider"),
|
||||
"current provider updated"
|
||||
);
|
||||
|
||||
let providers = state.db.get_all_providers(AppType::Codex.as_str())
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Codex.as_str())
|
||||
.expect("read providers after switch");
|
||||
|
||||
let new_provider = providers
|
||||
.get("new-provider")
|
||||
.expect("new provider exists");
|
||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||
let new_config_text = new_provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
@@ -385,11 +396,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
||||
"live settings.json should reflect new provider auth"
|
||||
);
|
||||
|
||||
let providers = state.db.get_all_providers(AppType::Claude.as_str())
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.expect("get all providers");
|
||||
let current_id = state.db.get_current_provider(AppType::Claude.as_str())
|
||||
let current_id = state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider");
|
||||
assert_eq!(current_id.as_deref(), Some("new-provider"), "current provider updated");
|
||||
assert_eq!(
|
||||
current_id.as_deref(),
|
||||
Some("new-provider"),
|
||||
"current provider updated"
|
||||
);
|
||||
|
||||
let legacy_provider = providers
|
||||
.get("old-provider")
|
||||
@@ -509,7 +528,9 @@ fn provider_service_delete_codex_removes_provider_and_files() {
|
||||
ProviderService::delete(&app_state, AppType::Codex, "to-delete")
|
||||
.expect("delete provider should succeed");
|
||||
|
||||
let providers = app_state.db.get_all_providers(AppType::Codex.as_str())
|
||||
let providers = app_state
|
||||
.db
|
||||
.get_all_providers(AppType::Codex.as_str())
|
||||
.expect("get all providers");
|
||||
assert!(
|
||||
!providers.contains_key("to-delete"),
|
||||
@@ -567,7 +588,9 @@ fn provider_service_delete_claude_removes_provider_files() {
|
||||
|
||||
ProviderService::delete(&app_state, AppType::Claude, "delete").expect("delete claude provider");
|
||||
|
||||
let providers = app_state.db.get_all_providers(AppType::Claude.as_str())
|
||||
let providers = app_state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.expect("get all providers");
|
||||
assert!(
|
||||
!providers.contains_key("delete"),
|
||||
@@ -608,15 +631,18 @@ fn provider_service_delete_current_provider_returns_error() {
|
||||
.expect_err("deleting current provider should fail");
|
||||
match err {
|
||||
AppError::Localized { zh, .. } => assert!(
|
||||
zh.contains("不能删除当前正在使用的供应商") || zh.contains("无法删除当前正在使用的供应商"),
|
||||
zh.contains("不能删除当前正在使用的供应商")
|
||||
|| zh.contains("无法删除当前正在使用的供应商"),
|
||||
"unexpected message: {zh}"
|
||||
),
|
||||
AppError::Config(msg) => assert!(
|
||||
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
|
||||
msg.contains("不能删除当前正在使用的供应商")
|
||||
|| msg.contains("无法删除当前正在使用的供应商"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
AppError::Message(msg) => assert!(
|
||||
msg.contains("不能删除当前正在使用的供应商") || msg.contains("无法删除当前正在使用的供应商"),
|
||||
msg.contains("不能删除当前正在使用的供应商")
|
||||
|| msg.contains("无法删除当前正在使用的供应商"),
|
||||
"unexpected message: {msg}"
|
||||
),
|
||||
other => panic!("expected Config/Message error, got {other:?}"),
|
||||
|
||||
@@ -74,6 +74,7 @@ function App() {
|
||||
switchProvider,
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setProxyTarget,
|
||||
} = useProviderActions(activeApp);
|
||||
|
||||
// 监听来自托盘菜单的切换事件
|
||||
@@ -303,20 +304,26 @@ function App() {
|
||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-6 space-y-4">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||
<div className="space-y-4">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
onSwitch={switchProvider}
|
||||
onSetProxyTarget={setProxyTarget}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -391,7 +398,7 @@ function App() {
|
||||
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
|
||||
{currentView === "skills" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
{currentView === "agents" && "Agents"}
|
||||
{currentView === "agents" && t("agents.title")}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -512,7 +519,7 @@ function App() {
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title="MCP"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -40,11 +40,7 @@ export function ConfirmDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-sm"
|
||||
zIndex="alert"
|
||||
overlayClassName="bg-background/80"
|
||||
>
|
||||
<DialogContent className="max-w-sm" zIndex="alert">
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider, UsageScript } from "@/types";
|
||||
import { Provider, UsageScript, UsageData } from "@/types";
|
||||
import { usageApi, type AppId } from "@/lib/api";
|
||||
import JsonEditor from "./JsonEditor";
|
||||
import * as prettier from "prettier/standalone";
|
||||
@@ -176,10 +176,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
|
||||
() => {
|
||||
const existingScript = provider.meta?.usage_script;
|
||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||
if (existingScript?.accessToken || existingScript?.userId) {
|
||||
return TEMPLATE_KEYS.NEW_API;
|
||||
}
|
||||
return null;
|
||||
// 检测 GENERAL 模板(有 apiKey 或 baseUrl)
|
||||
if (existingScript?.apiKey || existingScript?.baseUrl) {
|
||||
return TEMPLATE_KEYS.GENERAL;
|
||||
}
|
||||
// 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致)
|
||||
return TEMPLATE_KEYS.GENERAL;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -214,7 +220,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
);
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
const summary = result.data
|
||||
.map((plan) => {
|
||||
.map((plan: UsageData) => {
|
||||
const planInfo = plan.planName ? `[${plan.planName}]` : "";
|
||||
return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`;
|
||||
})
|
||||
@@ -349,14 +355,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
footer={footer}
|
||||
>
|
||||
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none text-foreground">
|
||||
{t("usageScript.enableUsageQuery")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.autoQueryIntervalHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-base font-medium leading-none text-foreground">
|
||||
{t("usageScript.enableUsageQuery")}
|
||||
</p>
|
||||
<Switch
|
||||
checked={script.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -370,14 +371,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
<div className="space-y-6">
|
||||
{/* 预设模板选择 */}
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.presetTemplate")}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("usageScript.variablesHint")}
|
||||
</span>
|
||||
</div>
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.presetTemplate")}
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.keys(PRESET_TEMPLATES).map((name) => {
|
||||
const isSelected = selectedTemplate === name;
|
||||
@@ -447,7 +443,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-base-url">Base URL</Label>
|
||||
<Label htmlFor="usage-base-url">
|
||||
{t("usageScript.baseUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-base-url"
|
||||
type="text"
|
||||
@@ -466,7 +464,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-newapi-base-url">Base URL</Label>
|
||||
<Label htmlFor="usage-newapi-base-url">
|
||||
{t("usageScript.baseUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-newapi-base-url"
|
||||
type="text"
|
||||
@@ -545,141 +545,36 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 脚本配置 */}
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-base font-medium text-foreground">
|
||||
{t("usageScript.scriptConfig")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.variablesHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* 通用配置(始终显示) */}
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
|
||||
{/* 超时时间 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-request-url">
|
||||
{t("usageScript.requestUrl")}
|
||||
<Label htmlFor="usage-timeout">
|
||||
{t("usageScript.timeoutSeconds")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-request-url"
|
||||
type="text"
|
||||
value={script.request?.url || ""}
|
||||
onChange={(e) => {
|
||||
id="usage-timeout"
|
||||
type="number"
|
||||
min={0}
|
||||
value={script.timeout ?? 10}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
request: { ...script.request, url: e.target.value },
|
||||
});
|
||||
}}
|
||||
placeholder={t("usageScript.requestUrlPlaceholder")}
|
||||
timeout: validateTimeout(e.target.value),
|
||||
})
|
||||
}
|
||||
onBlur={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
timeout: validateTimeout(e.target.value),
|
||||
})
|
||||
}
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-method">
|
||||
{t("usageScript.method")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-method"
|
||||
type="text"
|
||||
value={script.request?.method || "GET"}
|
||||
onChange={(e) => {
|
||||
setScript({
|
||||
...script,
|
||||
request: {
|
||||
...script.request,
|
||||
method: e.target.value.toUpperCase(),
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="GET / POST"
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-timeout">
|
||||
{t("usageScript.timeoutSeconds")}
|
||||
</Label>
|
||||
<Input
|
||||
id="usage-timeout"
|
||||
type="number"
|
||||
min={0}
|
||||
value={script.timeout ?? 10}
|
||||
onChange={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
timeout: validateTimeout(e.target.value),
|
||||
})
|
||||
}
|
||||
onBlur={(e) =>
|
||||
setScript({
|
||||
...script,
|
||||
timeout: validateTimeout(e.target.value),
|
||||
})
|
||||
}
|
||||
className="border-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-headers">
|
||||
{t("usageScript.headers")}
|
||||
</Label>
|
||||
<JsonEditor
|
||||
id="usage-headers"
|
||||
value={
|
||||
script.request?.headers
|
||||
? JSON.stringify(script.request.headers, null, 2)
|
||||
: "{}"
|
||||
}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "{}");
|
||||
setScript({
|
||||
...script,
|
||||
request: { ...script.request, headers: parsed },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Invalid headers JSON", error);
|
||||
}
|
||||
}}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
|
||||
<JsonEditor
|
||||
id="usage-body"
|
||||
value={
|
||||
script.request?.body
|
||||
? JSON.stringify(script.request.body, null, 2)
|
||||
: "{}"
|
||||
}
|
||||
onChange={(value) => {
|
||||
try {
|
||||
const parsed =
|
||||
value?.trim() === "" ? undefined : JSON.parse(value);
|
||||
setScript({
|
||||
...script,
|
||||
request: { ...script.request, body: parsed },
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
|
||||
);
|
||||
}
|
||||
}}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自动查询间隔 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-interval">
|
||||
{t("usageScript.autoIntervalMinutes")}
|
||||
@@ -708,9 +603,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
}
|
||||
className="border-white/10"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usageScript.autoQueryIntervalHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,22 +30,11 @@ export function SkillConfirmation({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground">
|
||||
{t("deeplink.skill.branch")}
|
||||
</label>
|
||||
<div className="mt-1 text-sm">{request.branch || "main"}</div>
|
||||
</div>
|
||||
|
||||
{request.skillsPath && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground">
|
||||
{t("deeplink.skill.skillsPath")}
|
||||
</label>
|
||||
<div className="mt-1 text-sm">{request.skillsPath}</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground">
|
||||
{t("deeplink.skill.branch")}
|
||||
</label>
|
||||
<div className="mt-1 text-sm">{request.branch || "main"}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-blue-600 dark:text-blue-400 text-sm bg-blue-50 dark:bg-blue-950/30 p-3 rounded border border-blue-200 dark:border-blue-800">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -224,16 +225,21 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("mcp.wizard.title")}</DialogTitle>
|
||||
<DialogContent
|
||||
className="max-w-2xl max-h-[90vh] flex flex-col"
|
||||
zIndex="alert"
|
||||
>
|
||||
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
{t("mcp.wizard.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Hint */}
|
||||
<div className="rounded-lg border border-border-active bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("mcp.wizard.hint")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -254,7 +260,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) =>
|
||||
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||
}
|
||||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||
className="w-4 h-4 accent-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{t("mcp.wizard.typeStdio")}
|
||||
@@ -268,7 +274,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) =>
|
||||
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||
}
|
||||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||
className="w-4 h-4 accent-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{t("mcp.wizard.typeHttp")}
|
||||
@@ -282,7 +288,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) =>
|
||||
setWizardType(e.target.value as "stdio" | "http" | "sse")
|
||||
}
|
||||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||||
className="w-4 h-4 accent-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{t("mcp.wizard.typeSse")}
|
||||
@@ -296,13 +302,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={wizardTitle}
|
||||
onChange={(e) => setWizardTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("mcp.form.titlePlaceholder")}
|
||||
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -315,13 +321,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
{t("mcp.wizard.command")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={wizardCommand}
|
||||
onChange={(e) => setWizardCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("mcp.wizard.commandPlaceholder")}
|
||||
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -335,7 +341,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) => setWizardArgs(e.target.value)}
|
||||
placeholder={t("mcp.wizard.argsPlaceholder")}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -349,7 +355,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) => setWizardEnv(e.target.value)}
|
||||
placeholder={t("mcp.wizard.envPlaceholder")}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -364,13 +370,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
{t("mcp.wizard.url")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={wizardUrl}
|
||||
onChange={(e) => setWizardUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("mcp.wizard.urlPlaceholder")}
|
||||
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -384,7 +390,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) => setWizardHeaders(e.target.value)}
|
||||
placeholder={t("mcp.wizard.headersPlaceholder")}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||||
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -397,11 +403,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
wizardEnv ||
|
||||
wizardUrl ||
|
||||
wizardHeaders) && (
|
||||
<div className="space-y-2 border-t border-border-default pt-4 ">
|
||||
<div className="space-y-2 border-t border-border-default pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("mcp.wizard.preview")}
|
||||
</h3>
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -409,11 +415,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="gap-3 pt-4">
|
||||
<Button type="button" variant="ghost" onClick={handleClose}>
|
||||
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" variant="mcp" onClick={handleApply}>
|
||||
<Button variant="mcp" onClick={handleApply}>
|
||||
<Save className="h-4 w-4" />
|
||||
{t("mcp.wizard.apply")}
|
||||
</Button>
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
Check,
|
||||
Copy,
|
||||
Edit,
|
||||
Loader2,
|
||||
Play,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderActionsProps {
|
||||
isCurrent: boolean;
|
||||
isTesting?: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
isCurrent,
|
||||
isTesting,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
}: ProviderActionsProps) {
|
||||
@@ -70,6 +83,23 @@ export function ProviderActions({
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{onTest && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
title={t("modelTest.testProvider", "测试模型")}
|
||||
className={iconButtonClass}
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -11,6 +11,8 @@ import { cn } from "@/lib/utils";
|
||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import UsageFooter from "@/components/UsageFooter";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface DragHandleProps {
|
||||
attributes: DraggableAttributes;
|
||||
@@ -28,6 +30,10 @@ interface ProviderCardProps {
|
||||
onConfigureUsage: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onTest?: (provider: Provider) => void;
|
||||
isTesting?: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
isProxyRunning: boolean;
|
||||
dragHandleProps?: DragHandleProps;
|
||||
}
|
||||
|
||||
@@ -76,6 +82,10 @@ export function ProviderCard({
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onDuplicate,
|
||||
onTest,
|
||||
isTesting,
|
||||
onSetProxyTarget,
|
||||
isProxyRunning,
|
||||
dragHandleProps,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -116,9 +126,7 @@ export function ProviderCard({
|
||||
className={cn(
|
||||
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
||||
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
||||
isCurrent
|
||||
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
|
||||
: "hover:scale-[1.01]",
|
||||
isCurrent ? "glass-card-active" : "hover:scale-[1.01]",
|
||||
dragHandleProps?.isDragging &&
|
||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||
)}
|
||||
@@ -166,14 +174,44 @@ export function ProviderCard({
|
||||
⭐
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
|
||||
isCurrent ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{t("provider.currentlyUsing")}
|
||||
</span>
|
||||
|
||||
{/* 代理目标开关 - 仅在代理服务运行时显示 */}
|
||||
{isProxyRunning && (
|
||||
<div
|
||||
className="flex items-center gap-2 ml-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Switch
|
||||
id={`proxy-target-switch-${provider.id}`}
|
||||
checked={provider.isProxyTarget || false}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked && !provider.isProxyTarget) {
|
||||
onSetProxyTarget(provider);
|
||||
}
|
||||
}}
|
||||
disabled={provider.isProxyTarget}
|
||||
className="scale-75 data-[state=checked]:bg-purple-500"
|
||||
/>
|
||||
{provider.isProxyTarget && (
|
||||
<Label
|
||||
htmlFor={`proxy-target-switch-${provider.id}`}
|
||||
className="text-xs font-medium text-purple-500 dark:text-purple-400 cursor-pointer"
|
||||
>
|
||||
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
|
||||
</Label>
|
||||
)}
|
||||
{!provider.isProxyTarget && (
|
||||
<Label
|
||||
htmlFor={`proxy-target-switch-${provider.id}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{t("provider.setAsProxyTarget", {
|
||||
defaultValue: "设为代理",
|
||||
})}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayUrl && (
|
||||
@@ -210,9 +248,11 @@ export function ProviderCard({
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
isTesting={isTesting}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { CSSProperties } from "react";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useModelTest } from "@/hooks/useModelTest";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
|
||||
@@ -24,6 +26,7 @@ interface ProviderListProps {
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onCreate?: () => void;
|
||||
isLoading?: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -38,12 +41,23 @@ export function ProviderList({
|
||||
onOpenWebsite,
|
||||
onCreate,
|
||||
isLoading = false,
|
||||
onSetProxyTarget,
|
||||
}: ProviderListProps) {
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
providers,
|
||||
appId,
|
||||
);
|
||||
|
||||
// 获取代理服务运行状态
|
||||
const { isRunning: isProxyRunning } = useProxyStatus();
|
||||
|
||||
// 模型测试
|
||||
const { testProvider, isTesting } = useModelTest(appId);
|
||||
|
||||
const handleTest = (provider: Provider) => {
|
||||
testProvider(provider.id, provider.name);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -87,6 +101,10 @@ export function ProviderList({
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onTest={handleTest}
|
||||
isTesting={isTesting(provider.id)}
|
||||
onSetProxyTarget={onSetProxyTarget}
|
||||
isProxyRunning={isProxyRunning}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -105,6 +123,10 @@ interface SortableProviderCardProps {
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onTest: (provider: Provider) => void;
|
||||
isTesting: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
isProxyRunning: boolean;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
@@ -117,6 +139,10 @@ function SortableProviderCard({
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onTest,
|
||||
isTesting,
|
||||
onSetProxyTarget,
|
||||
isProxyRunning,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -146,6 +172,10 @@ function SortableProviderCard({
|
||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||
}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
onSetProxyTarget={onSetProxyTarget}
|
||||
isProxyRunning={isProxyRunning}
|
||||
dragHandleProps={{
|
||||
attributes,
|
||||
listeners,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useBaseUrlState({
|
||||
try {
|
||||
const config = JSON.parse(settingsConfig || "{}");
|
||||
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
|
||||
if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) {
|
||||
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
|
||||
setBaseUrl(envUrl.trim());
|
||||
}
|
||||
} catch {
|
||||
@@ -73,8 +73,7 @@ export function useBaseUrlState({
|
||||
try {
|
||||
const config = JSON.parse(settingsConfig || "{}");
|
||||
const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;
|
||||
const nextUrl =
|
||||
typeof envUrl === "string" ? envUrl.trim().replace(/\/+$/, "") : "";
|
||||
const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
|
||||
if (nextUrl !== geminiBaseUrl) {
|
||||
setGeminiBaseUrl(nextUrl);
|
||||
setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI
|
||||
@@ -87,7 +86,7 @@ export function useBaseUrlState({
|
||||
// 处理 Claude Base URL 变化
|
||||
const handleClaudeBaseUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
const sanitized = url.trim().replace(/\/+$/, "");
|
||||
const sanitized = url.trim();
|
||||
setBaseUrl(sanitized);
|
||||
isUpdatingRef.current = true;
|
||||
|
||||
@@ -112,7 +111,7 @@ export function useBaseUrlState({
|
||||
// 处理 Codex Base URL 变化
|
||||
const handleCodexBaseUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
const sanitized = url.trim().replace(/\/+$/, "");
|
||||
const sanitized = url.trim();
|
||||
setCodexBaseUrl(sanitized);
|
||||
|
||||
if (!sanitized || !onCodexConfigChange) {
|
||||
@@ -136,7 +135,7 @@ export function useBaseUrlState({
|
||||
// 处理 Gemini Base URL 变化
|
||||
const handleGeminiBaseUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
const sanitized = url.trim().replace(/\/+$/, "");
|
||||
const sanitized = url.trim();
|
||||
setGeminiBaseUrl(sanitized);
|
||||
setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI
|
||||
isUpdatingRef.current = true;
|
||||
|
||||
@@ -162,7 +162,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||
// 处理 Codex Base URL 变化
|
||||
const handleCodexBaseUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
const sanitized = url.trim().replace(/\/+$/, "");
|
||||
const sanitized = url.trim();
|
||||
setCodexBaseUrl(sanitized);
|
||||
|
||||
if (!sanitized) {
|
||||
|
||||