Compare commits

..

2 Commits

Author SHA1 Message Date
YoVinchen 6889e1d248 fix(proxy): persist per-app takeover state across app restarts
- Fix proxy toggle color to reflect current app's takeover state only
- Restore proxy service on startup if Live config is still in takeover state
- Preserve per-app backup records instead of clearing all on restart
- Only recover Live config when proxy service fails to start
2025-12-19 15:38:16 +08:00
YoVinchen 771bef16f5 fix(ui): improve AboutSection styling and version detection
- Add framer-motion animations for smooth page transitions
- Unify button sizes and add icons for consistency
- Add gradient backgrounds and hover effects to cards
- Add notInstalled i18n translations (zh/en/ja)
- Fix version detection when stdout/stderr is empty
2025-12-19 14:20:43 +08:00
161 changed files with 2732 additions and 9793 deletions
+1 -1
View File
@@ -1 +1 @@
22.12.0 v22.4.1
-58
View File
@@ -5,64 +5,6 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.9.0-3] - 2025-12-29
### Beta Release
Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features.
### Added
- **Universal Provider** - Support for universal provider configurations (#348)
- **Provider Search Filter** - Quick filter to find providers by name (#435)
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)
- **Usage Model Extraction** - Extract model info from usage statistics (#455)
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
- **Exit Animations** - Added exit animation to FullScreenPanel dialogs
- **Fade Transitions** - Smooth fade transitions for app/view/panel switching
### Fixed
#### Windows
- Wrap npx/npm commands with `cmd /c` for MCP export
- Prevent terminal windows from appearing during version check
#### macOS
- Use .app bundle path for autostart to prevent terminal window popup
#### UI
- Resolve Dialog/Modal not opening on first click (#492)
- Improve dark mode text contrast for form labels
- Reduce header spacing and fix layout shift on view switch
- Prevent header layout shift when switching views
#### Database & Schema
- Add missing base columns migration for proxy_config
- Add backward compatibility check for proxy_config seed insert
#### Other
- Use local timezone and robust DST handling in usage stats (#500)
- Remove deprecated `sync_enabled_to_codex` call
- Gracefully handle invalid Codex config.toml during MCP sync
- Add missing translations for reasoning model and OpenRouter compat mode
### Improved
- **macOS Tray** - Use macOS tray template icon
- **Header Alignment** - Remove macOS titlebar tint, align custom header
- **Shadow Removal** - Cleaner UI by removing shadow styles
- **Code Inspector** - Added code-inspector-plugin for development
- **i18n** - Complete internationalization for usage panel and settings
- **Sponsor Logos** - Made sponsor logos clickable
### Stats
- 35 commits since v3.9.0-2
- 5 files changed in test/lint fixes
---
## [3.9.0-1] - 2025-12-18 ## [3.9.0-1] - 2025-12-18
### Beta Release ### Beta Release
+9 -8
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI # All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,7 +15,7 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
## ❤️Sponsor ## ❤️Sponsor
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) ![Zhipu GLM](assets/partners/banners/glm-en.jpg)
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)! This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
@@ -23,18 +23,19 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
<table> <table>
<tr> <tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td> <td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr> </tr>
<tr> <tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td> <td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr> </tr>
<tr> <tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td> <td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td> <td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, youll receive an extra 10% bonus credit on your first top-up!
</td>
</tr> </tr>
</table> </table>
@@ -47,7 +48,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features ## Features
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md) ### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
**v3.8.0 Major Update (2025-11-28)** **v3.8.0 Major Update (2025-11-28)**
+10 -8
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント # Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,7 +15,7 @@
## ❤️スポンサー ## ❤️スポンサー
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) ![Zhipu GLM](assets/partners/banners/glm-en.jpg)
本プロジェクトは 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% オフになります! 本プロジェクトは 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% オフになります!
@@ -23,18 +23,20 @@
<table> <table>
<tr> <tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td> <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> <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>
<tr> <tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td> <td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
</tr> </tr>
<tr> <tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td> <td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td> <td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
</td>
</tr> </tr>
</table> </table>
@@ -47,7 +49,7 @@
## 特長 ## 特長
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md) ### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
**v3.8.0 メジャーアップデート (2025-11-28)** **v3.8.0 メジャーアップデート (2025-11-28)**
+10 -10
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具 # Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,7 +15,7 @@
## ❤️赞助商 ## ❤️赞助商
[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII) ![智谱 GLM](assets/partners/banners/glm-zh.jpg)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。 感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
@@ -23,20 +23,20 @@
<table> <table>
<tr> <tr>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td> <td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
</tr> </tr>
<tr> <tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td> <td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td> <td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr> </tr>
<tr>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
</tr>
</table> </table>
## 界面预览 ## 界面预览
@@ -47,7 +47,7 @@
## 功能特性 ## 功能特性
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md) ### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
**v3.8.0 重大更新(2025-11-28** **v3.8.0 重大更新(2025-11-28**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
"rsc": false, "rsc": false,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.cjs", "config": "tailwind.config.js",
"css": "src/index.css", "css": "src/index.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
+3 -5
View File
@@ -1,8 +1,7 @@
{ {
"name": "cc-switch", "name": "cc-switch",
"version": "3.9.0-3", "version": "3.9.0-1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": { "scripts": {
"dev": "pnpm tauri dev", "dev": "pnpm tauri dev",
"build": "pnpm tauri build", "build": "pnpm tauri build",
@@ -28,7 +27,6 @@
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"code-inspector-plugin": "^1.3.3",
"cross-fetch": "^4.1.0", "cross-fetch": "^4.1.0",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"msw": "^2.11.6", "msw": "^2.11.6",
@@ -36,7 +34,7 @@
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^7.3.0", "vite": "^5.0.0",
"vitest": "^2.0.5" "vitest": "^2.0.5"
}, },
"dependencies": { "dependencies": {
@@ -87,4 +85,4 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+25 -517
View File
File diff suppressed because it is too large Load Diff
+5 -57
View File
@@ -586,12 +586,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.10.1"
@@ -701,7 +695,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.9.0-3" version = "3.9.0-1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@@ -2220,7 +2214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"png 0.17.16", "png",
] ]
[[package]] [[package]]
@@ -2336,19 +2330,6 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.0",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2778,16 +2759,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "moxcms"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.1"
@@ -2803,7 +2774,7 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png 0.17.16", "png",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -3592,19 +3563,6 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.9.4",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -3736,15 +3694,6 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -5038,7 +4987,6 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range", "http-range",
"image",
"jni", "jni",
"libc", "libc",
"log", "log",
@@ -5107,7 +5055,7 @@ dependencies = [
"ico", "ico",
"json-patch", "json-patch",
"plist", "plist",
"png 0.17.16", "png",
"proc-macro2", "proc-macro2",
"quote", "quote",
"semver", "semver",
@@ -5862,7 +5810,7 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png 0.17.16", "png",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.59.0", "windows-sys 0.59.0",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.9.0-3" version = "3.9.0-1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
@@ -26,7 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] } tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

+4 -71
View File
@@ -1,36 +1,16 @@
use crate::error::AppError; use crate::error::AppError;
use auto_launch::{AutoLaunch, AutoLaunchBuilder}; use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 获取 macOS 上的 .app bundle 路径
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
#[cfg(target_os = "macos")]
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
let path_str = exe_path.to_string_lossy();
// 查找 .app/Contents/MacOS/ 模式
if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") {
let app_bundle_end = app_pos + 4; // ".app" 的结束位置
Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))
} else {
None
}
}
/// 初始化 AutoLaunch 实例 /// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> { fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch"; let app_name = "CC Switch";
let exe_path = let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?; std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
#[cfg(target_os = "macos")]
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
#[cfg(not(target_os = "macos"))]
let app_path = exe_path;
// 使用 AutoLaunchBuilder 消除平台差异 // 使用 AutoLaunchBuilder 消除平台差异
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径 // Windows/Linux: new() 接受 3 参数
// Windows/Linux: 使用注册表/XDG autostart // macOS: new() 接受 4 参数(含 hidden 参数)
// Builder 模式自动处理这些差异
let auto_launch = AutoLaunchBuilder::new() let auto_launch = AutoLaunchBuilder::new()
.set_app_name(app_name) .set_app_name(app_name)
.set_app_path(&app_path.to_string_lossy()) .set_app_path(&app_path.to_string_lossy())
@@ -67,50 +47,3 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
.is_enabled() .is_enabled()
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}"))) .map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
} }
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_valid() {
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_with_spaces() {
let exe_path =
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(
result,
Some(std::path::PathBuf::from(
"/Users/test/My Apps/CC Switch.app"
))
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_not_in_bundle() {
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
#[cfg(target_os = "macos")]
#[test]
fn test_get_macos_app_bundle_path_dev_build() {
// 开发环境下的路径通常不在 .app bundle 内
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
let result = get_macos_app_bundle_path(exe_path);
assert_eq!(result, None);
}
}
-243
View File
@@ -7,64 +7,6 @@ use std::path::{Path, PathBuf};
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path}; use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
use crate::error::AppError; use crate::error::AppError;
/// 需要在 Windows 上用 cmd /c 包装的命令
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
#[cfg(windows)]
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
#[cfg(windows)]
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
// 只处理 stdio 类型(默认或显式)
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
if server_type != "stdio" {
return;
}
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
return;
};
// 已经是 cmd 的不重复包装
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
return;
}
// 提取命令名(去掉 .cmd 后缀和路径)
let cmd_name = Path::new(cmd)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(cmd);
let needs_wrap = WINDOWS_WRAP_COMMANDS
.iter()
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
if !needs_wrap {
return;
}
// 构建新的 args: ["/c", "原命令", ...原args]
let original_args = obj
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
new_args.extend(original_args);
obj.insert("command".into(), Value::String("cmd".into()));
obj.insert("args".into(), Value::Array(new_args));
}
/// 非 Windows 平台无需处理
#[cfg(not(windows))]
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
// 非 Windows 平台不做任何处理
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct McpStatus { pub struct McpStatus {
@@ -163,55 +105,6 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content)) Ok(Some(content))
} }
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
/// 仅增量写入该字段,其他字段保持不变
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let already = obj
.get("hasCompletedOnboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already {
return Ok(false);
}
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
write_json_value(&path, &root)?;
Ok(true)
}
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
/// 仅增量删除该字段,其他字段保持不变
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let existed = obj.remove("hasCompletedOnboarding").is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> { pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() { if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
@@ -397,9 +290,6 @@ pub fn set_mcp_servers_map(
obj.remove("homepage"); obj.remove("homepage");
obj.remove("docs"); obj.remove("docs");
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
wrap_command_for_windows(&mut obj);
out.insert(id.clone(), Value::Object(obj)); out.insert(id.clone(), Value::Object(obj));
} }
@@ -413,136 +303,3 @@ pub fn set_mcp_servers_map(
write_json_value(&path, &root)?; write_json_value(&path, &root)?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 测试 Windows 命令包装功能
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
#[test]
fn test_wrap_command_for_windows_npx() {
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(
obj["args"],
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
);
}
#[cfg(not(windows))]
{
// 非 Windows 平台不做任何处理
assert_eq!(obj["command"], "npx");
}
}
#[test]
fn test_wrap_command_for_windows_npm() {
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
}
}
#[test]
fn test_wrap_command_for_windows_already_cmd() {
// 已经是 cmd 的不应该重复包装
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert_eq!(obj["command"], "cmd");
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
}
#[test]
fn test_wrap_command_for_windows_http_type_skipped() {
// http 类型不应该被处理
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
assert!(!obj.contains_key("command"));
assert_eq!(obj["url"], "https://example.com/mcp");
}
#[test]
fn test_wrap_command_for_windows_other_command_skipped() {
// 非目标命令(如 python)不应该被包装
let mut obj = json!({"command": "python", "args": ["server.py"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
assert_eq!(obj["command"], "python");
assert_eq!(obj["args"], json!(["server.py"]));
}
#[test]
fn test_wrap_command_for_windows_no_args() {
// 没有 args 的情况
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx"]));
}
}
#[test]
fn test_wrap_command_for_windows_with_cmd_suffix() {
// 处理 npx.cmd 格式
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
}
}
#[test]
fn test_wrap_command_for_windows_case_insensitive() {
// 大小写不敏感
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
.as_object()
.unwrap()
.clone();
wrap_command_for_windows(&mut obj);
#[cfg(windows)]
{
assert_eq!(obj["command"], "cmd");
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
}
}
}
+10 -28
View File
@@ -1,6 +1,6 @@
//! 故障转移队列命令 //! 故障转移队列命令
//! //!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段) //! 管理代理模式下的故障转移队列
use crate::database::FailoverQueueItem; use crate::database::FailoverQueueItem;
use crate::provider::Provider; use crate::provider::Provider;
@@ -56,47 +56,29 @@ pub async fn remove_from_failover_queue(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取) /// 重新排序故障转移队列
#[tauri::command] #[tauri::command]
pub async fn get_auto_failover_enabled( pub async fn reorder_failover_queue(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app_type: String, app_type: String,
) -> Result<bool, String> { provider_ids: Vec<String>,
) -> Result<(), String> {
state state
.db .db
.get_proxy_config_for_app(&app_type) .reorder_failover_queue(&app_type, &provider_ids)
.await
.map(|config| config.auto_failover_enabled)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表) /// 设置故障转移队列项的启用状态
///
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
#[tauri::command] #[tauri::command]
pub async fn set_auto_failover_enabled( pub async fn set_failover_item_enabled(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app_type: String, app_type: String,
provider_id: String,
enabled: bool, enabled: bool,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
// 读取当前配置
let mut config = state
.db
.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())?;
// 更新 auto_failover_enabled 字段
config.auto_failover_enabled = enabled;
// 写回数据库
state state
.db .db
.update_proxy_config_for_app(config) .set_failover_item_enabled(&app_type, &provider_id, enabled)
.await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
+6 -35
View File
@@ -4,12 +4,6 @@ use crate::init_status::InitErrorPayload;
use tauri::AppHandle; use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// 打开外部链接 /// 打开外部链接
#[tauri::command] #[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> { pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
@@ -148,16 +142,11 @@ fn extract_version(raw: &str) -> String {
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) { fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command; use std::process::Command;
#[cfg(target_os = "windows")] let output = if cfg!(target_os = "windows") {
let output = {
Command::new("cmd") Command::new("cmd")
.args(["/C", &format!("{tool} --version")]) .args(["/C", &format!("{tool} --version")])
.creation_flags(CREATE_NO_WINDOW)
.output() .output()
}; } else {
#[cfg(not(target_os = "windows"))]
let output = {
Command::new("sh") Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!("{tool} --version")) .arg(format!("{tool} --version"))
@@ -248,30 +237,12 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
if tool_path.exists() { if tool_path.exists() {
// 构建 PATH 环境变量,确保 node 可被找到 // 构建 PATH 环境变量,确保 node 可被找到
let current_path = std::env::var("PATH").unwrap_or_default(); let current_path = std::env::var("PATH").unwrap_or_default();
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", path.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", path.display(), current_path); let new_path = format!("{}:{}", path.display(), current_path);
#[cfg(target_os = "windows")] let output = Command::new(&tool_path)
let output = { .arg("--version")
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行 .env("PATH", &new_path)
Command::new("cmd") .output();
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output()
};
if let Ok(out) = output { if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
-12
View File
@@ -34,15 +34,3 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
pub async fn is_claude_plugin_applied() -> Result<bool, String> { pub async fn is_claude_plugin_applied() -> Result<bool, String> {
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string()) crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
} }
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true
#[tauri::command]
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
}
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
#[tauri::command]
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
}
-94
View File
@@ -229,97 +229,3 @@ pub fn update_providers_sort_order(
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
} }
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
UniversalProviderSyncedEvent {
action: action.to_string(),
id: id.to_string(),
},
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
) -> Result<HashMap<String, UniversalProvider>, String> {
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
}
/// 获取单个统一供应商
#[tauri::command]
pub fn get_universal_provider(
state: State<'_, AppState>,
id: String,
) -> Result<Option<UniversalProvider>, String> {
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
}
/// 添加或更新统一供应商
#[tauri::command]
pub fn upsert_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
provider: UniversalProvider,
) -> Result<bool, String> {
let id = provider.id.clone();
let result =
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "upsert", &id);
Ok(result)
}
/// 删除统一供应商
#[tauri::command]
pub fn delete_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "delete", &id);
Ok(result)
}
/// 同步统一供应商到各应用(手动触发)
#[tauri::command]
pub fn sync_universal_provider(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let result =
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
// 发送事件通知前端刷新
emit_universal_provider_synced(&app, "sync", &id);
Ok(result)
}
+9 -125
View File
@@ -11,7 +11,15 @@ use crate::store::AppState;
pub async fn start_proxy_server( pub async fn start_proxy_server(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> { ) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await state.proxy_service.start(true).await
}
/// 启动代理服务器(带 Live 配置接管)
#[tauri::command]
pub async fn start_proxy_with_takeover(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start_with_takeover().await
} }
/// 停止代理服务器(恢复 Live 配置) /// 停止代理服务器(恢复 Live 配置)
@@ -62,63 +70,6 @@ pub async fn update_proxy_config(
state.proxy_service.update_config(&config).await state.proxy_service.update_config(&config).await
} }
// ==================== Global & Per-App Config ====================
/// 获取全局代理配置
///
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
#[tauri::command]
pub async fn get_global_proxy_config(
state: tauri::State<'_, AppState>,
) -> Result<GlobalProxyConfig, String> {
let db = &state.db;
db.get_global_proxy_config()
.await
.map_err(|e| e.to_string())
}
/// 更新全局代理配置
///
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini
#[tauri::command]
pub async fn update_global_proxy_config(
state: tauri::State<'_, AppState>,
config: GlobalProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_global_proxy_config(config)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的代理配置
///
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())
}
/// 更新指定应用的代理配置
///
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn update_proxy_config_for_app(
state: tauri::State<'_, AppState>,
config: AppProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行 /// 检查代理服务器是否正在运行
#[tauri::command] #[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> { pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
@@ -160,13 +111,8 @@ pub async fn get_provider_health(
} }
/// 重置熔断器 /// 重置熔断器
///
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
/// 1. 检查自动故障转移是否开启
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
#[tauri::command] #[tauri::command]
pub async fn reset_circuit_breaker( pub async fn reset_circuit_breaker(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
provider_id: String, provider_id: String,
app_type: String, app_type: String,
@@ -183,68 +129,6 @@ pub async fn reset_circuit_breaker(
.reset_provider_circuit_breaker(&provider_id, &app_type) .reset_provider_circuit_breaker(&provider_id, &app_type)
.await?; .await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
false
}
};
if auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID
let current_id = db
.get_current_provider(&app_type)
.map_err(|e| e.to_string())?;
if let Some(current_id) = current_id {
// 获取故障转移队列
let queue = db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index
let restored_order = queue
.iter()
.find(|item| item.provider_id == provider_id)
.and_then(|item| item.sort_index);
let current_order = queue
.iter()
.find(|item| item.provider_id == current_id)
.and_then(|item| item.sort_index);
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
if let (Some(restored), Some(current)) = (restored_order, current_order) {
if restored < current {
log::info!(
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
);
// 获取供应商名称用于日志和事件
let provider_name = db
.get_all_providers(&app_type)
.ok()
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
.unwrap_or_else(|| provider_id.clone());
// 创建故障转移切换管理器并执行切换
let switch_manager =
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
if let Err(e) = switch_manager
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
.await
{
log::error!("[Recovery] 自动切换失败: {e}");
}
}
}
}
}
Ok(()) Ok(())
} }
+3 -1
View File
@@ -52,7 +52,9 @@ pub async fn stream_check_all_providers(
} }
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) { if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
for item in queue { for item in queue {
ids.insert(item.provider_id); if item.enabled {
ids.insert(item.provider_id);
}
} }
} }
Some(ids) Some(ids)
+22 -23
View File
@@ -13,8 +13,6 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database { impl Database {
/// 导出为 SQLite 兼容的 SQL 文本 /// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
@@ -38,8 +36,7 @@ impl Database {
} }
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = sql_raw.trim_start_matches('\u{feff}'); let sql_content = Self::sanitize_import_sql(&sql_raw);
Self::validate_cc_switch_sql_export(sql_content)?;
// 导入前备份现有数据库 // 导入前备份现有数据库
let backup_path = self.backup_database_file()?; let backup_path = self.backup_database_file()?;
@@ -54,7 +51,7 @@ impl Database {
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn temp_conn
.execute_batch(sql_content) .execute_batch(&sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验 // 补齐缺失表/索引并进行基础校验
@@ -96,17 +93,26 @@ impl Database {
Ok(snapshot) Ok(snapshot)
} }
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> { /// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
let trimmed = sql.trim_start(); fn sanitize_import_sql(sql: &str) -> String {
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) { let mut cleaned = String::new();
return Ok(()); let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
} }
Err(AppError::localized( cleaned
"backup.sql.invalid_format",
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
"Only SQL backups exported by CC Switch are supported.",
))
} }
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
@@ -123,15 +129,8 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let mut backup_id = base_id.clone(); let backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
{ {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
+130 -35
View File
@@ -1,32 +1,36 @@
//! 故障转移队列 DAO //! 故障转移队列 DAO
//! //!
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段) //! 管理代理模式下的故障转移队列
use crate::database::{lock_conn, Database}; use crate::database::{lock_conn, Database};
use crate::error::AppError; use crate::error::AppError;
use crate::provider::Provider; use crate::provider::Provider;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// 故障转移队列条目(简化版,用于前端展示) /// 故障转移队列条目
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FailoverQueueItem { pub struct FailoverQueueItem {
pub provider_id: String, pub provider_id: String,
pub provider_name: String, pub provider_name: String,
pub sort_index: Option<usize>, pub queue_order: i32,
pub enabled: bool,
pub created_at: i64,
} }
impl Database { impl Database {
/// 获取故障转移队列(按 sort_index 排序) /// 获取故障转移队列(按 queue_order 排序)
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> { pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT id, name, sort_index "SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
FROM providers FROM failover_queue fq
WHERE app_type = ?1 AND in_failover_queue = 1 JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
ORDER BY COALESCE(sort_index, 999999), id ASC", WHERE fq.app_type = ?1
ORDER BY fq.queue_order ASC",
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,7 +39,9 @@ impl Database {
Ok(FailoverQueueItem { Ok(FailoverQueueItem {
provider_id: row.get(0)?, provider_id: row.get(0)?,
provider_name: row.get(1)?, provider_name: row.get(1)?,
sort_index: row.get(2)?, queue_order: row.get(2)?,
enabled: row.get(3)?,
created_at: row.get(4)?,
}) })
}) })
.map_err(|e| AppError::Database(e.to_string()))? .map_err(|e| AppError::Database(e.to_string()))?
@@ -47,23 +53,43 @@ impl Database {
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序) /// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> { pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let queue = self.get_failover_queue(app_type)?;
let all_providers = self.get_all_providers(app_type)?; let all_providers = self.get_all_providers(app_type)?;
let result: Vec<Provider> = all_providers let mut result = Vec::new();
.into_values() for item in queue {
.filter(|p| p.in_failover_queue) if item.enabled {
.collect(); if let Some(provider) = all_providers.get(&item.provider_id) {
result.push(provider.clone());
}
}
}
Ok(result) Ok(result)
} }
/// 添加供应商到故障转移队列 /// 添加供应商到故障转移队列末尾
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> { pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order
let max_order: i32 = conn
.query_row(
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute( conn.execute(
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2", "INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
rusqlite::params![provider_id, app_type], VALUES (?1, ?2, ?3, 1, ?4)",
rusqlite::params![app_type, provider_id, max_order + 1, now],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -78,22 +104,90 @@ impl Database {
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 1. 从队列中移除 // 获取被删除项的 queue_order
let removed_order: Option<i32> = conn
.query_row(
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
|row| row.get(0),
)
.ok();
// 删除该项
conn.execute( conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2", "DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![provider_id, app_type], [app_type, provider_id],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控 // 重新排序后面的项(填补空隙
if let Some(order) = removed_order {
conn.execute(
"UPDATE failover_queue
SET queue_order = queue_order - 1
WHERE app_type = ?1 AND queue_order > ?2",
rusqlite::params![app_type, order],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
/// 重新排序故障转移队列
/// provider_ids: 按新顺序排列的 provider_id 列表
pub fn reorder_failover_queue(
&self,
app_type: &str,
provider_ids: &[String],
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 使用事务确保原子性
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let result = (|| {
for (index, provider_id) in provider_ids.iter().enumerate() {
conn.execute(
"UPDATE failover_queue
SET queue_order = ?3
WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, (index + 1) as i32],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
})();
match result {
Ok(_) => {
conn.execute("COMMIT", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
Err(e) => {
conn.execute("ROLLBACK", []).ok();
Err(e)
}
}
}
/// 设置故障转移队列中供应商的启用状态
pub fn set_failover_item_enabled(
&self,
app_type: &str,
provider_id: &str,
enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute( conn.execute(
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2", "UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![provider_id, app_type], rusqlite::params![app_type, provider_id, enabled],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
Ok(()) Ok(())
} }
@@ -101,11 +195,8 @@ impl Database {
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> { pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.execute( conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1", .map_err(|e| AppError::Database(e.to_string()))?;
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -118,15 +209,15 @@ impl Database {
) -> Result<bool, AppError> { ) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let in_queue: bool = conn let count: i32 = conn
.query_row( .query_row(
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", "SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![provider_id, app_type], [app_type, provider_id],
|row| row.get(0), |row| row.get(0),
) )
.unwrap_or(false); .map_err(|e| AppError::Database(e.to_string()))?;
Ok(in_queue) Ok(count > 0)
} }
/// 获取可添加到故障转移队列的供应商(不在队列中的) /// 获取可添加到故障转移队列的供应商(不在队列中的)
@@ -135,10 +226,14 @@ impl Database {
app_type: &str, app_type: &str,
) -> Result<Vec<Provider>, AppError> { ) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?; let all_providers = self.get_all_providers(app_type)?;
let queue = self.get_failover_queue(app_type)?;
let queue_ids: std::collections::HashSet<_> =
queue.iter().map(|item| &item.provider_id).collect();
let available: Vec<Provider> = all_providers let available: Vec<Provider> = all_providers
.into_values() .into_values()
.filter(|p| !p.in_failover_queue) .filter(|p| !queue_ids.contains(&p.id))
.collect(); .collect();
Ok(available) Ok(available)
-1
View File
@@ -10,7 +10,6 @@ pub mod proxy;
pub mod settings; pub mod settings;
pub mod skills; pub mod skills;
pub mod stream_check; pub mod stream_check;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用 // 导出 FailoverQueueItem 供外部使用
+11 -19
View File
@@ -17,7 +17,7 @@ impl Database {
) -> Result<IndexMap<String, Provider>, AppError> { ) -> Result<IndexMap<String, Provider>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
FROM providers WHERE app_type = ?1 FROM providers WHERE app_type = ?1
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?; ).map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,7 +35,6 @@ impl Database {
let icon: Option<String> = row.get(8)?; let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = row.get(9)?; let icon_color: Option<String> = row.get(9)?;
let meta_str: String = row.get(10)?; let meta_str: String = row.get(10)?;
let in_failover_queue: bool = row.get(11)?;
let settings_config = let settings_config =
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
@@ -55,7 +54,6 @@ impl Database {
meta: Some(meta), meta: Some(meta),
icon, icon,
icon_color, icon_color,
in_failover_queue,
}, },
)) ))
}) })
@@ -131,7 +129,7 @@ impl Database {
) -> Result<Option<Provider>, AppError> { ) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let result = conn.query_row( let result = conn.query_row(
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue "SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
FROM providers WHERE id = ?1 AND app_type = ?2", FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type], params![id, app_type],
|row| { |row| {
@@ -145,7 +143,6 @@ impl Database {
let icon: Option<String> = row.get(7)?; let icon: Option<String> = row.get(7)?;
let icon_color: Option<String> = row.get(8)?; let icon_color: Option<String> = row.get(8)?;
let meta_str: String = row.get(9)?; let meta_str: String = row.get(9)?;
let in_failover_queue: bool = row.get(10)?;
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); 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(); let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
@@ -162,7 +159,6 @@ impl Database {
meta: Some(meta), meta: Some(meta),
icon, icon,
icon_color, icon_color,
in_failover_queue,
}) })
}, },
); );
@@ -188,18 +184,17 @@ impl Database {
let mut meta_clone = provider.meta.clone().unwrap_or_default(); let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue // 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<(bool, bool)> = tx let existing: Option<bool> = tx
.query_row( .query_row(
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", "SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type], params![provider.id, app_type],
|row| Ok((row.get(0)?, row.get(1)?)), |row| row.get(0),
) )
.ok(); .ok();
let is_update = existing.is_some(); let is_update = existing.is_some();
let (is_current, in_failover_queue) = let is_current = existing.unwrap_or(false);
existing.unwrap_or((false, provider.in_failover_queue));
if is_update { if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
@@ -215,9 +210,8 @@ impl Database {
icon = ?8, icon = ?8,
icon_color = ?9, icon_color = ?9,
meta = ?10, meta = ?10,
is_current = ?11, is_current = ?11
in_failover_queue = ?12 WHERE id = ?12 AND app_type = ?13",
WHERE id = ?13 AND app_type = ?14",
params![ params![
provider.name, provider.name,
serde_json::to_string(&provider.settings_config).unwrap(), serde_json::to_string(&provider.settings_config).unwrap(),
@@ -230,7 +224,6 @@ impl Database {
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone).unwrap(),
is_current, is_current,
in_failover_queue,
provider.id, provider.id,
app_type, app_type,
], ],
@@ -241,8 +234,8 @@ impl Database {
tx.execute( tx.execute(
"INSERT INTO providers ( "INSERT INTO providers (
id, app_type, name, settings_config, website_url, category, id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue 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, ?14)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![ params![
provider.id, provider.id,
app_type, app_type,
@@ -257,7 +250,6 @@ impl Database {
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone).unwrap(),
is_current, is_current,
in_failover_queue,
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
+79 -321
View File
@@ -8,254 +8,63 @@ use crate::proxy::types::*;
use super::super::{lock_conn, Database}; use super::super::{lock_conn, Database};
impl Database { impl Database {
// ==================== Global Proxy Config ==================== // ==================== Proxy Config ====================
/// 获取全局代理配置(统一字段) /// 获取代理配置
///
/// 从 claude 行读取(三行镜像一致)
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(GlobalProxyConfig {
proxy_enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
enable_logging: row.get::<_, i32>(3)? != 0,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(GlobalProxyConfig {
proxy_enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 5000,
enable_logging: true,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新全局代理配置(镜像写三行)
pub async fn update_global_proxy_config(
&self,
config: GlobalProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
proxy_enabled = ?1,
listen_address = ?2,
listen_port = ?3,
enable_logging = ?4,
updated_at = datetime('now')",
rusqlite::params![
if config.proxy_enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
if config.enable_logging { 1 } else { 0 },
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取应用级代理配置
pub async fn get_proxy_config_for_app(
&self,
app_type: &str,
) -> Result<AppProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let app_type_owned = app_type.to_string();
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT app_type, enabled, auto_failover_enabled,
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| {
Ok(AppProxyConfig {
app_type: row.get(0)?,
enabled: row.get::<_, i32>(1)? != 0,
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
max_retries: row.get::<_, i32>(3)? as u32,
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
circuit_error_rate_threshold: row.get(10)?,
circuit_min_requests: row.get::<_, i32>(11)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(AppProxyConfig {
app_type: app_type_owned,
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 300,
circuit_failure_threshold: 5,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.5,
circuit_min_requests: 10,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新应用级代理配置
pub async fn update_proxy_config_for_app(
&self,
config: AppProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE proxy_config SET
enabled = ?2,
auto_failover_enabled = ?3,
max_retries = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
circuit_failure_threshold = ?8,
circuit_success_threshold = ?9,
circuit_timeout_seconds = ?10,
circuit_error_rate_threshold = ?11,
circuit_min_requests = ?12,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![
config.app_type,
if config.enabled { 1 } else { 0 },
if config.auto_failover_enabled { 1 } else { 0 },
config.max_retries as i32,
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
config.circuit_failure_threshold as i32,
config.circuit_success_threshold as i32,
config.circuit_timeout_seconds as i32,
config.circuit_error_rate_threshold,
config.circuit_min_requests as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
for app_type in &["claude", "codex", "gemini"] {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
// ==================== Legacy Proxy Config (兼容旧代码) ====================
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> { pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁 // 在一个作用域内获取锁并查询,确保锁在await之前释放
let result = { let result = {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
"SELECT listen_address, listen_port, max_retries, "SELECT enabled, listen_address, listen_port, max_retries,
enable_logging, request_timeout, enable_logging, live_takeover_active
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout FROM proxy_config WHERE id = 1",
FROM proxy_config WHERE app_type = 'claude'",
[], [],
|row| { |row| {
Ok(ProxyConfig { Ok(ProxyConfig {
listen_address: row.get(0)?, enabled: row.get::<_, i32>(0)? != 0,
listen_port: row.get::<_, i32>(1)? as u16, listen_address: row.get(1)?,
max_retries: row.get::<_, i32>(2)? as u8, listen_port: row.get::<_, i32>(2)? as u16,
request_timeout: 300, // 废弃字段,返回默认值 max_retries: row.get::<_, i32>(3)? as u8,
enable_logging: row.get::<_, i32>(3)? != 0, request_timeout: row.get::<_, i32>(4)? as u64,
live_takeover_active: false, // 废弃字段 enable_logging: row.get::<_, i32>(5)? != 0,
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64, live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
}) })
}, },
) )
}; }; // conn锁在这里释放
// conn 已在 block 结束时释放
match result { match result {
Ok(config) => Ok(config), Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => { Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置 // 如果不存在,插入默认配置
self.init_proxy_config_rows().await?; let default_config = ProxyConfig::default();
Ok(ProxyConfig::default()) self.update_proxy_config(default_config.clone()).await?;
Ok(default_config)
} }
Err(e) => Err(AppError::Database(e.to_string())), Err(e) => Err(AppError::Database(e.to_string())),
} }
} }
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段) /// 更新代理配置
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> { pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 更新所有三行的公共字段
conn.execute( conn.execute(
"UPDATE proxy_config SET "INSERT OR REPLACE INTO proxy_config
listen_address = ?1, (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
listen_port = ?2, VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
max_retries = ?3, COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
enable_logging = ?4, datetime('now'))",
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
updated_at = datetime('now')",
rusqlite::params![ rusqlite::params![
if config.enabled { 1 } else { 0 },
config.listen_address, config.listen_address,
config.listen_port as i32, config.listen_port as i32,
config.max_retries as i32, config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 }, if config.enable_logging { 1 } else { 0 },
config.streaming_first_byte_timeout as i32, if config.live_takeover_active { 1 } else { 0 },
config.streaming_idle_timeout as i32, "claude", // 兼容旧字段,写入默认值
config.non_streaming_timeout as i32,
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -263,26 +72,21 @@ impl Database {
Ok(()) Ok(())
} }
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段) /// 设置 Live 接管状态
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> { pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
// 不再使用此字段,由 enabled 字段替代 let conn = lock_conn!(self.conn);
// 保留空实现以兼容旧代码 conn.execute(
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
rusqlite::params![if active { 1 } else { 0 }],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }
/// 检查是否处于 Live 接管模式 /// 检查是否处于 Live 接管模式
///
/// 检查是否有任一 app 的 enabled = true
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> { pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn); // v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
let count: i64 = conn self.has_any_live_backup().await
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
} }
// ==================== Provider Health ==================== // ==================== Provider Health ====================
@@ -293,45 +97,28 @@ impl Database {
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
) -> Result<ProviderHealth, AppError> { ) -> Result<ProviderHealth, AppError> {
let result = { let conn = lock_conn!(self.conn);
let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
"SELECT provider_id, app_type, is_healthy, consecutive_failures, "SELECT provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at last_success_at, last_failure_at, last_error, updated_at
FROM provider_health FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2", WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type], rusqlite::params![provider_id, app_type],
|row| { |row| {
Ok(ProviderHealth { Ok(ProviderHealth {
provider_id: row.get(0)?, provider_id: row.get(0)?,
app_type: row.get(1)?, app_type: row.get(1)?,
is_healthy: row.get::<_, i64>(2)? != 0, is_healthy: row.get::<_, i64>(2)? != 0,
consecutive_failures: row.get::<_, i64>(3)? as u32, consecutive_failures: row.get::<_, i64>(3)? as u32,
last_success_at: row.get(4)?, last_success_at: row.get(4)?,
last_failure_at: row.get(5)?, last_failure_at: row.get(5)?,
last_error: row.get(6)?, last_error: row.get(6)?,
updated_at: row.get(7)?, updated_at: row.get(7)?,
}) })
}, },
) )
}; .map_err(|e| AppError::Database(e.to_string()))
match result {
Ok(health) => Ok(health),
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
provider_id: provider_id.to_string(),
app_type: app_type.to_string(),
is_healthy: true,
consecutive_failures: 0,
last_success_at: None,
last_failure_at: None,
last_error: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}),
Err(e) => Err(AppError::Database(e.to_string())),
}
} }
/// 更新Provider健康状态 /// 更新Provider健康状态
@@ -436,20 +223,6 @@ impl Database {
Ok(()) Ok(())
} }
/// 清空指定应用的健康状态(关闭单个代理时使用)
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_health WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Cleared provider health records for app {app_type}");
Ok(())
}
/// 清空所有Provider健康状态(代理停止时调用) /// 清空所有Provider健康状态(代理停止时调用)
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> { pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
@@ -461,22 +234,19 @@ impl Database {
Ok(()) Ok(())
} }
// ==================== Circuit Breaker Config (Legacy Compatibility) ==================== // ==================== Circuit Breaker Config ====================
/// 获取熔断器配置(兼容旧接口,从 claude 行读取) /// 获取熔断器配置
///
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
pub async fn get_circuit_breaker_config( pub async fn get_circuit_breaker_config(
&self, &self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> { ) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁 let conn = lock_conn!(self.conn);
let result = {
let conn = lock_conn!(self.conn); let config = conn
conn.query_row( .query_row(
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, "SELECT failure_threshold, success_threshold, timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests error_rate_threshold, min_requests
FROM proxy_config WHERE app_type = 'claude'", FROM circuit_breaker_config WHERE id = 1",
[], [],
|row| { |row| {
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig { Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
@@ -488,39 +258,27 @@ impl Database {
}) })
}, },
) )
}; .map_err(|e| AppError::Database(e.to_string()))?;
// conn 已在 block 结束时释放
match result { Ok(config)
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
} }
/// 更新熔断器配置(兼容旧接口,更新所有三行) /// 更新熔断器配置
///
/// 熔断器配置已合并到 proxy_config 表
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
pub async fn update_circuit_breaker_config( pub async fn update_circuit_breaker_config(
&self, &self,
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig, config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 更新所有三行的熔断器配置
conn.execute( conn.execute(
"UPDATE proxy_config SET "UPDATE circuit_breaker_config
circuit_failure_threshold = ?1, SET failure_threshold = ?1,
circuit_success_threshold = ?2, success_threshold = ?2,
circuit_timeout_seconds = ?3, timeout_seconds = ?3,
circuit_error_rate_threshold = ?4, error_rate_threshold = ?4,
circuit_min_requests = ?5, min_requests = ?5,
updated_at = datetime('now')", updated_at = CURRENT_TIMESTAMP
WHERE id = 1",
rusqlite::params![ rusqlite::params![
config.failure_threshold as i32, config.failure_threshold as i32,
config.success_threshold as i32, config.success_threshold as i32,
-66
View File
@@ -62,70 +62,4 @@ impl Database {
Ok(()) Ok(())
} }
} }
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
/// 此方法仅用于数据库迁移时读取旧数据
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
let key = format!("proxy_takeover_{app_type}");
match self.get_setting(&key)? {
Some(value) => Ok(value == "true"),
None => Ok(false),
}
}
/// 设置指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
)]
pub fn set_proxy_takeover_enabled(
&self,
app_type: &str,
enabled: bool,
) -> Result<(), AppError> {
let key = format!("proxy_takeover_{app_type}");
let value = if enabled { "true" } else { "false" };
self.set_setting(&key, value)
}
/// 检查是否有任一应用开启了代理接管
///
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false
///
/// **已废弃**: settings 表不再用于存储代理状态
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
)]
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已清除所有代理接管状态");
Ok(())
}
} }
@@ -1,74 +0,0 @@
//! 统一供应商 (Universal Provider) DAO
//!
//! 提供统一供应商的 CRUD 操作。
use crate::database::{lock_conn, to_json_string, Database};
use crate::error::AppError;
use crate::provider::UniversalProvider;
use std::collections::HashMap;
/// 统一供应商的 Settings Key
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
impl Database {
/// 获取所有统一供应商
pub fn get_all_universal_providers(
&self,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?")
.map_err(|e| AppError::Database(e.to_string()))?;
let result: Option<String> = stmt
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
.ok();
match result {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
None => Ok(HashMap::new()),
}
}
/// 获取单个统一供应商
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
let providers = self.get_all_universal_providers()?;
Ok(providers.get(id).cloned())
}
/// 保存统一供应商(添加或更新)
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
let mut providers = self.get_all_universal_providers()?;
providers.insert(provider.id.clone(), provider.clone());
self.save_all_universal_providers(&providers)
}
/// 删除统一供应商
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
let mut providers = self.get_all_universal_providers()?;
let existed = providers.remove(id).is_some();
if existed {
self.save_all_universal_providers(&providers)?;
}
Ok(existed)
}
/// 保存所有统一供应商(内部方法)
fn save_all_universal_providers(
&self,
providers: &HashMap<String, UniversalProvider>,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let json = to_json_string(providers)?;
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
[UNIVERSAL_PROVIDERS_KEY, &json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
+254 -397
View File
@@ -31,7 +31,6 @@ impl Database {
icon_color TEXT, icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0, is_current BOOLEAN NOT NULL DEFAULT 0,
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type) PRIMARY KEY (id, app_type)
)", )",
[], [],
@@ -55,28 +54,47 @@ impl Database {
// 3. MCP Servers 表 // 3. MCP Servers 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS mcp_servers ( "CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, id TEXT PRIMARY KEY,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', name TEXT NOT NULL,
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, server_config TEXT NOT NULL,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0 description TEXT,
)", homepage TEXT,
docs TEXT,
tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 4. Prompts 表 // 4. Prompts 表
conn.execute("CREATE TABLE IF NOT EXISTS prompts ( conn.execute(
id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL, "CREATE TABLE IF NOT EXISTS prompts (
description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER, id TEXT NOT NULL,
PRIMARY KEY (id, app_type) app_type TEXT NOT NULL,
)", []).map_err(|e| AppError::Database(e.to_string()))?; name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表 // 5. Skills 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS skills ( "CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0, directory TEXT NOT NULL,
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type) app_type TEXT NOT NULL,
)", installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -84,130 +102,169 @@ impl Database {
// 6. Skill Repos 表 // 6. Skill Repos 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS skill_repos ( "CREATE TABLE IF NOT EXISTS skill_repos (
owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main', owner TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name) name TEXT NOT NULL,
)", branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (owner, name)
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 7. Settings 表 // 7. Settings 表 (通用配置)
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)", "CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 8. Proxy Config 表(三行结构,app_type 主键) // 8. Proxy Config 表 (代理服务器配置)
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config ( // 代理配置表(单例)
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 初始化三行数据(每应用不同默认值)
//
// 兼容旧数据库:
// - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert
// - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。
if Self::has_column(conn, "proxy_config", "app_type")? {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// 9. Provider Health 表
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 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( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)", "CREATE TABLE IF NOT EXISTS proxy_config (
[], id INTEGER PRIMARY KEY CHECK (id = 1),
) enabled INTEGER NOT NULL DEFAULT 0,
.map_err(|e| AppError::Database(e.to_string()))?; listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
conn.execute( listen_port INTEGER NOT NULL DEFAULT 5000,
"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)", max_retries INTEGER NOT NULL DEFAULT 3,
[], request_timeout INTEGER NOT NULL DEFAULT 300,
) enable_logging INTEGER NOT NULL DEFAULT 1,
.map_err(|e| AppError::Database(e.to_string()))?; target_app TEXT NOT NULL DEFAULT 'claude',
conn.execute( created_at TEXT NOT NULL DEFAULT (datetime('now')),
"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)", updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 11. Model Pricing 表 // 尝试添加 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 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()))?;
// 11. Model Pricing 表 (模型定价)
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing ( "CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, model_id TEXT PRIMARY KEY,
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL, display_name TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', input_cost_per_million TEXT NOT NULL,
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' 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()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 12. Stream Check Logs 表 // 12. Stream Check Logs 表 (流式健康检查日志)
conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs ( conn.execute(
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL, "CREATE TABLE IF NOT EXISTS stream_check_logs (
app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
response_time_ms INTEGER, http_status INTEGER, model_used TEXT, provider_id TEXT NOT NULL,
retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL provider_name TEXT NOT NULL,
)", []).map_err(|e| AppError::Database(e.to_string()))?; app_type TEXT NOT NULL,
status TEXT NOT NULL,
success INTEGER NOT NULL,
message TEXT NOT NULL,
response_time_ms INTEGER,
http_status INTEGER,
model_used TEXT,
retry_count INTEGER DEFAULT 0,
tested_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider "CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
@@ -216,13 +273,35 @@ impl Database {
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 注意:circuit_breaker_config 已合并到 proxy_config 表中 // 13. Circuit Breaker Config 表 (熔断器配置)
conn.execute(
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
failure_threshold INTEGER NOT NULL DEFAULT 5,
success_threshold INTEGER NOT NULL DEFAULT 2,
timeout_seconds INTEGER NOT NULL DEFAULT 60,
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
min_requests INTEGER NOT NULL DEFAULT 10,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 插入默认熔断器配置
conn.execute(
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 16. Proxy Live Backup 表 (Live 配置备份) // 16. Proxy Live Backup 表 (Live 配置备份)
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_live_backup ( "CREATE TABLE IF NOT EXISTS proxy_live_backup (
app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL app_type TEXT PRIMARY KEY,
)", original_config TEXT NOT NULL,
backed_up_at TEXT NOT NULL
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -233,64 +312,29 @@ impl Database {
[], [],
); );
// 尝试添加基础配置列到 proxy_config 表(兼容 v3.9.0-2 升级) // 14. Failover Queue 表 (故障转移队列)
let _ = conn.execute( conn.execute(
"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0", "CREATE TABLE IF NOT EXISTS failover_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
queue_order INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
UNIQUE (app_type, provider_id),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
[], [],
); )
let _ = conn.execute( .map_err(|e| AppError::Database(e.to_string()))?;
"ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 5000",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
[],
);
// 尝试添加超时配置列到 proxy_config 表 // 为故障转移队列创建索引
let _ = conn.execute( conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30", "CREATE INDEX IF NOT EXISTS idx_failover_queue_order
ON failover_queue(app_type, queue_order)",
[], [],
); )
let _ = conn.execute( .map_err(|e| AppError::Database(e.to_string()))?;
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
[],
);
// 兼容:若旧版 proxy_config 仍为单例结构(无 app_type),则在启动时直接转换为三行结构
// 说明:user_version=2 时不会再触发 v1->v2 迁移,但新代码查询依赖 app_type 列。
if Self::table_exists(conn, "proxy_config")?
&& !Self::has_column(conn, "proxy_config", "app_type")?
{
Self::migrate_proxy_config_to_per_app(conn)?;
}
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
Self::add_column_if_missing(
conn,
"providers",
"in_failover_queue",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 删除旧的 failover_queue 表(如果存在)
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
// 为故障转移队列创建索引(基于 providers 表)
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_providers_failover
ON providers(app_type, in_failover_queue, sort_index)",
[],
);
Ok(()) Ok(())
} }
@@ -428,87 +472,36 @@ impl Database {
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?; 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", "limit_monthly_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?; Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
Self::add_column_if_missing(
conn,
"providers",
"in_failover_queue",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 添加代理超时配置字段 // proxy_request_logs 表(包含所有字段
if Self::table_exists(conn, "proxy_config")? {
// 兼容旧版本缺失的基础字段
Self::add_column_if_missing(
conn,
"proxy_config",
"proxy_enabled",
"INTEGER NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"listen_address",
"TEXT NOT NULL DEFAULT '127.0.0.1'",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"listen_port",
"INTEGER NOT NULL DEFAULT 5000",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"enable_logging",
"INTEGER NOT NULL DEFAULT 1",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_first_byte_timeout",
"INTEGER NOT NULL DEFAULT 30",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 60",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"non_streaming_timeout",
"INTEGER NOT NULL DEFAULT 300",
)?;
}
// 删除旧的 failover_queue 表(如果存在)
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
// 创建 failover 索引
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_providers_failover "CREATE TABLE IF NOT EXISTS proxy_request_logs (
ON providers(app_type, in_failover_queue, sort_index)", 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(format!("创建 failover 索引失败: {e}")))?;
// 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", "provider_type", "TEXT")?;
@@ -530,11 +523,13 @@ impl Database {
// model_pricing 表 // model_pricing 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing ( "CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, model_id TEXT PRIMARY KEY,
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL, display_name TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', input_cost_per_million TEXT NOT NULL,
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' 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'
)",
[], [],
)?; )?;
@@ -546,144 +541,6 @@ impl Database {
// 重构 skills 表(添加 app_type 字段) // 重构 skills 表(添加 app_type 字段)
Self::migrate_skills_table(conn)?; Self::migrate_skills_table(conn)?;
// 重构 proxy_config 为三行结构(每应用独立配置)
Self::migrate_proxy_config_to_per_app(conn)?;
Ok(())
}
/// 将 proxy_config 迁移为三行结构(每应用独立配置)
fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
// 检查是否已经是新表结构(幂等性)
if !Self::table_exists(conn, "proxy_config")? {
// 表不存在,跳过迁移(新安装)
return Ok(());
}
if Self::has_column(conn, "proxy_config", "app_type")? {
// 已经是三行结构,跳过迁移
log::info!("proxy_config 已经是三行结构,跳过迁移");
return Ok(());
}
// 读取旧配置
let old_config = conn
.query_row(
"SELECT listen_address, listen_port, max_retries, enable_logging,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
FROM proxy_config WHERE id = 1",
[],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i32>(1)?,
row.get::<_, i32>(2)?,
row.get::<_, i32>(3)?,
row.get::<_, i32>(4).unwrap_or(30),
row.get::<_, i32>(5).unwrap_or(60),
row.get::<_, i32>(6).unwrap_or(300),
))
},
)
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
let old_cb = conn.query_row(
"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
FROM circuit_breaker_config WHERE id = 1", [],
|row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
).unwrap_or((5, 2, 60, 0.5, 10));
let get_bool = |key: &str| -> bool {
conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
r.get::<_, String>(0)
})
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
};
let apps = [
(
"claude",
get_bool("proxy_takeover_claude"),
get_bool("auto_failover_enabled_claude"),
6,
45,
90,
8,
3,
90,
0.6,
15,
),
(
"codex",
get_bool("proxy_takeover_codex"),
get_bool("auto_failover_enabled_codex"),
3,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
(
"gemini",
get_bool("proxy_takeover_gemini"),
get_bool("auto_failover_enabled_gemini"),
5,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
];
// 创建新表
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
// 插入三行配置
for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
conn.execute(
"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
rusqlite::params![app, old_config.0, old_config.1, old_config.3,
if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
}
// 替换表并清理
conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
conn.execute(
"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
[],
)?;
log::info!("proxy_config 已迁移为三行结构");
Ok(()) Ok(())
} }
+7 -54
View File
@@ -53,6 +53,7 @@ const LEGACY_SCHEMA_SQL: &str = r#"
#[derive(Debug)] #[derive(Debug)]
struct ColumnInfo { struct ColumnInfo {
name: String,
r#type: String, r#type: String,
notnull: i64, notnull: i64,
default: Option<String>, default: Option<String>,
@@ -64,9 +65,10 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
.expect("prepare pragma"); .expect("prepare pragma");
let mut rows = stmt.query([]).expect("query pragma"); let mut rows = stmt.query([]).expect("query pragma");
while let Some(row) = rows.next().expect("read row") { while let Some(row) = rows.next().expect("read row") {
let column_name: String = row.get(1).expect("name"); let name: String = row.get(1).expect("name");
if column_name.eq_ignore_ascii_case(column) { if name.eq_ignore_ascii_case(column) {
return ColumnInfo { return ColumnInfo {
name,
r#type: row.get::<_, String>(2).expect("type"), r#type: row.get::<_, String>(2).expect("type"),
notnull: row.get::<_, i64>(3).expect("notnull"), notnull: row.get::<_, i64>(3).expect("notnull"),
default: row.get::<_, Option<String>>(4).ok().flatten(), default: row.get::<_, Option<String>>(4).ok().flatten(),
@@ -199,53 +201,6 @@ fn migration_aligns_column_defaults_and_types() {
); );
} }
#[test]
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_version=2,但 proxy_config 仍是单例结构(无 app_type
Database::set_user_version(&conn, 2).expect("set user_version");
conn.execute_batch(
r#"
CREATE TABLE proxy_config (
id INTEGER PRIMARY KEY,
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'))
);
INSERT INTO proxy_config (id, enabled) VALUES (1, 1);
"#,
)
.expect("seed legacy proxy_config");
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
assert!(
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
"proxy_config should be migrated to per-app structure"
);
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'",
[],
|r| r.get(0),
)
.expect("query by app_type");
}
#[test] #[test]
fn dry_run_does_not_write_to_disk() { fn dry_run_does_not_write_to_disk() {
// Create minimal valid config for migration // Create minimal valid config for migration
@@ -290,14 +245,12 @@ fn dry_run_validates_schema_compatibility() {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
}, },
); );
let manager = ProviderManager { let mut manager = ProviderManager::default();
providers, manager.providers = providers;
current: "test-provider".to_string(), manager.current = "test-provider".to_string();
};
let mut apps = HashMap::new(); let mut apps = HashMap::new();
apps.insert("claude".to_string(), manager); apps.insert("claude".to_string(), manager);
-1
View File
@@ -132,7 +132,6 @@ pub(crate) fn build_provider_from_request(
meta, meta,
icon: request.icon.clone(), icon: request.icon.clone(),
icon_color: None, icon_color: None,
in_failover_queue: false,
}; };
Ok(provider) Ok(provider)
+3 -3
View File
@@ -375,7 +375,7 @@ fn test_parse_prompt_deeplink() {
assert_eq!(request.name.unwrap(), "test"); assert_eq!(request.name.unwrap(), "test");
assert_eq!(request.content.unwrap(), content_b64); assert_eq!(request.content.unwrap(), content_b64);
assert_eq!(request.description.unwrap(), "desc"); assert_eq!(request.description.unwrap(), "desc");
assert!(request.enabled.unwrap()); assert_eq!(request.enabled.unwrap(), true);
} }
#[test] #[test]
@@ -391,13 +391,13 @@ fn test_parse_mcp_deeplink() {
assert_eq!(request.resource, "mcp"); assert_eq!(request.resource, "mcp");
assert_eq!(request.apps.unwrap(), "claude,codex"); assert_eq!(request.apps.unwrap(), "claude,codex");
assert_eq!(request.config.unwrap(), config_b64); assert_eq!(request.config.unwrap(), config_b64);
assert!(request.enabled.unwrap()); assert_eq!(request.enabled.unwrap(), true);
} }
#[test] #[test]
fn test_parse_skill_deeplink() { fn test_parse_skill_deeplink() {
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev"; let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
let request = parse_deeplink_url(url).unwrap(); let request = parse_deeplink_url(&url).unwrap();
assert_eq!(request.resource, "skill"); assert_eq!(request.resource, "skill");
assert_eq!(request.repo.unwrap(), "owner/repo"); assert_eq!(request.repo.unwrap(), "owner/repo");
+114 -140
View File
@@ -48,8 +48,6 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc; use std::sync::Arc;
#[cfg(target_os = "macos")]
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::RunEvent; use tauri::RunEvent;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -135,19 +133,6 @@ async fn update_tray_menu(
} }
} }
#[cfg(target_os = "macos")]
fn macos_tray_icon() -> Option<Image<'static>> {
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
match Image::from_bytes(ICON_BYTES) {
Ok(icon) => Some(icon),
Err(err) => {
log::warn!("Failed to load macOS tray icon: {err}");
None
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let mut builder = tauri::Builder::default(); let mut builder = tauri::Builder::default();
@@ -223,6 +208,44 @@ pub fn run() {
log::warn!("初始化 Updater 插件失败,已跳过:{e}"); log::warn!("初始化 Updater 插件失败,已跳过:{e}");
} }
} }
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
if let Some(window) = app.get_webview_window("main") {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
match window.ns_window() {
Ok(ns_window_ptr) => {
if let Some(ns_window) =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
{
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
unsafe {
use objc2::msg_send;
let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
}
}
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
}
}
}
// 初始化日志 // 初始化日志
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
@@ -480,26 +503,11 @@ pub fn run() {
}) })
.show_menu_on_left_click(true); .show_menu_on_left_click(true);
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色) // 统一使用应用默认图标;待托盘模板图标就绪后再启用
#[cfg(target_os = "macos")] if let Some(icon) = app.default_window_icon() {
{ tray_builder = tray_builder.icon(icon.clone());
if let Some(icon) = macos_tray_icon() { } else {
tray_builder = tray_builder.icon(icon).icon_as_template(true); log::warn!("Failed to get default window icon for tray");
} else if let Some(icon) = app.default_window_icon() {
log::warn!("Falling back to default window icon for tray");
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to load macOS tray icon for tray");
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
}
} }
let _tray = tray_builder.build(app)?; let _tray = tray_builder.build(app)?;
@@ -516,12 +524,12 @@ pub fn run() {
} }
} }
// 异常退出恢复 + 代理状态自动恢复 // 异常退出恢复 + 自动启动代理服务器
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态) // 检查是否有 Live 备份(表示上次接管状态)
let has_backups = match state.db.has_any_live_backup().await { let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
@@ -529,20 +537,57 @@ pub fn run() {
false false
} }
}; };
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if has_backups || live_taken_over { // 获取代理配置
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置..."); let proxy_config = match state.db.get_proxy_config().await {
if let Err(e) = state.proxy_service.recover_from_crash().await { Ok(config) => Some(config),
log::error!("恢复 Live 配置失败: {e}"); Err(e) => {
log::error!("启动时获取代理配置失败: {e}");
None
}
};
if has_backups {
// 有备份说明上次退出时有接管状态
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if live_taken_over {
// Live 配置仍是接管状态,尝试重新启动代理服务以恢复接管
log::info!("检测到上次接管状态,正在重新启动代理服务...");
match state.proxy_service.start(false).await {
Ok(info) => {
log::info!("代理服务器已恢复启动: {}:{}", info.address, info.port);
}
Err(e) => {
// 启动失败,恢复 Live 配置
log::error!("恢复代理服务失败: {e},正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}");
} else {
log::info!("Live 配置已恢复");
}
}
}
} else { } else {
log::info!("Live 配置已恢复"); // Live 配置已经是正常状态,清理残留备份
log::info!("Live 配置已是正常状态,清理残留备份...");
if let Err(e) = state.db.delete_all_live_backups().await {
log::warn!("清理残留 Live 备份失败: {e}");
}
}
} else if let Some(config) = proxy_config {
// 没有备份,检查是否需要自动启动代理服务器(总开关)
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start(true).await {
Ok(info) => {
log::info!("代理服务器自动启动成功: {}:{}", info.address, info.port)
}
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
} }
} }
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
}); });
Ok(()) Ok(())
@@ -580,8 +625,6 @@ pub fn run() {
commands::read_claude_plugin_config, commands::read_claude_plugin_config,
commands::apply_claude_plugin_config, commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied, commands::is_claude_plugin_applied,
commands::apply_claude_onboarding_skip,
commands::clear_claude_onboarding_skip,
// Claude MCP management // Claude MCP management
commands::get_claude_mcp_status, commands::get_claude_mcp_status,
commands::read_claude_mcp_config, commands::read_claude_mcp_config,
@@ -596,7 +639,7 @@ pub fn run() {
commands::upsert_mcp_server_in_config, commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config, commands::delete_mcp_server_in_config,
commands::set_mcp_enabled, commands::set_mcp_enabled,
// Unified MCP management // v3.7.0: Unified MCP management
commands::get_mcp_servers, commands::get_mcp_servers,
commands::upsert_mcp_server, commands::upsert_mcp_server,
commands::delete_mcp_server, commands::delete_mcp_server,
@@ -650,17 +693,13 @@ pub fn run() {
commands::get_auto_launch_status, commands::get_auto_launch_status,
// Proxy server management // Proxy server management
commands::start_proxy_server, commands::start_proxy_server,
commands::start_proxy_with_takeover,
commands::stop_proxy_with_restore, commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status, commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app, commands::set_proxy_takeover_for_app,
commands::get_proxy_status, commands::get_proxy_status,
commands::get_proxy_config, commands::get_proxy_config,
commands::update_proxy_config, commands::update_proxy_config,
// Global & Per-App Config
commands::get_global_proxy_config,
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::is_proxy_running, commands::is_proxy_running,
commands::is_live_takeover_active, commands::is_live_takeover_active,
commands::switch_proxy_provider, commands::switch_proxy_provider,
@@ -675,8 +714,8 @@ pub fn run() {
commands::get_available_providers_for_failover, commands::get_available_providers_for_failover,
commands::add_to_failover_queue, commands::add_to_failover_queue,
commands::remove_from_failover_queue, commands::remove_from_failover_queue,
commands::get_auto_failover_enabled, commands::reorder_failover_queue,
commands::set_auto_failover_enabled, commands::set_failover_item_enabled,
// Usage statistics // Usage statistics
commands::get_usage_summary, commands::get_usage_summary,
commands::get_usage_trends, commands::get_usage_trends,
@@ -694,12 +733,6 @@ pub fn run() {
commands::get_stream_check_config, commands::get_stream_check_config,
commands::save_stream_check_config, commands::save_stream_check_config,
commands::get_tool_versions, commands::get_tool_versions,
// Universal Provider management
commands::get_universal_providers,
commands::get_universal_provider,
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
]); ]);
let app = builder let app = builder
@@ -814,91 +847,32 @@ pub fn run() {
/// ///
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。 /// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。 /// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<store::AppState>() { if let Some(state) = app_handle.try_state::<store::AppState>() {
let proxy_service = &state.proxy_service; let proxy_service = &state.proxy_service;
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。 // 检查代理是否在运行
let has_backups = match state.db.has_any_live_backup().await {
Ok(v) => v,
Err(e) => {
log::error!("退出时检查 Live 备份失败: {e}");
false
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
if needs_restore {
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
// 使用 keep_state 版本,保留 settings 表中的代理状态
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
log::error!("退出时恢复 Live 配置失败: {e}");
} else {
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
}
return;
}
// 非接管模式:代理在运行则仅停止代理
if proxy_service.is_running().await { if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始停止..."); log::info!("检测到代理服务器正在运行,开始清理...");
if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}");
}
log::info!("代理服务器清理完成");
}
}
}
// ============================================================ // 检查是否处于 Live 接管模式
// 启动时恢复代理状态 if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
// ============================================================ if is_takeover {
// 接管模式:停止并恢复配置
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务 if let Err(e) = proxy_service.stop_with_restore().await {
/// log::error!("退出时恢复 Live 配置失败: {e}");
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`, } else {
/// 则自动启动代理服务并接管对应应用的 Live 配置。 log::info!("已恢复 Live 配置");
async fn restore_proxy_state_on_startup(state: &store::AppState) { }
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取) } else {
let mut apps_to_restore = Vec::new(); // 非接管模式:仅停止代理
for app_type in ["claude", "codex", "gemini"] { if let Err(e) = proxy_service.stop().await {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await { log::error!("退出时停止代理失败: {e}");
if config.enabled { }
apps_to_restore.push(app_type);
}
}
}
if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
return;
}
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
// 逐个恢复接管状态
for app_type in apps_to_restore {
match state
.proxy_service
.set_takeover_for_app(app_type, true)
.await
{
Ok(()) => {
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
}
Err(e) => {
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
// 失败时清除该应用的状态,避免下次启动再次尝试
if let Err(clear_err) = state
.proxy_service
.set_takeover_for_app(app_type, false)
.await
{
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
} }
} }
log::info!("代理服务器清理完成");
} }
} }
} }
+6 -16
View File
@@ -359,14 +359,9 @@ pub fn sync_single_server_to_codex(
let mut doc = if config_path.exists() { let mut doc = if config_path.exists() {
let content = let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
// 尝试解析现有配置,如果失败则创建新文档(容错处理) content
match content.parse::<toml_edit::DocumentMut>() { .parse::<toml_edit::DocumentMut>()
Ok(doc) => doc, .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
toml_edit::DocumentMut::new()
}
}
} else { } else {
toml_edit::DocumentMut::new() toml_edit::DocumentMut::new()
}; };
@@ -414,14 +409,9 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let content = let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容) let mut doc = content
let mut doc = match content.parse::<toml_edit::DocumentMut>() { .parse::<toml_edit::DocumentMut>()
Ok(doc) => doc, .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
return Ok(());
}
};
// 从正确的位置删除:[mcp_servers] // 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) { if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
-288
View File
@@ -36,10 +36,6 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")] #[serde(rename = "iconColor")]
pub icon_color: Option<String>, pub icon_color: Option<String>,
/// 是否加入故障转移队列
#[serde(default)]
#[serde(rename = "inFailoverQueue")]
pub in_failover_queue: bool,
} }
impl Provider { impl Provider {
@@ -62,7 +58,6 @@ impl Provider {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
} }
@@ -173,286 +168,3 @@ impl ProviderManager {
&self.providers &self.providers
} }
} }
// ============================================================================
// 统一供应商(Universal Provider- 跨应用共享配置
// ============================================================================
/// 统一供应商的应用启用状态
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
/// Claude 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClaudeModelConfig {
/// 主模型
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Haiku 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "haikuModel")]
pub haiku_model: Option<String>,
/// Sonnet 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sonnetModel")]
pub sonnet_model: Option<String>,
/// Opus 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "opusModel")]
pub opus_model: Option<String>,
}
/// Codex 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodexModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 推理强度
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "reasoningEffort")]
pub reasoning_effort: Option<String>,
}
/// Gemini 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GeminiModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// 各应用的模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderModels {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<ClaudeModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex: Option<CodexModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gemini: Option<GeminiModelConfig>,
}
/// 统一供应商(跨应用共享配置)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniversalProvider {
/// 唯一标识
pub id: String,
/// 供应商名称
pub name: String,
/// 供应商类型(如 "newapi", "custom"
#[serde(rename = "providerType")]
pub provider_type: String,
/// 应用启用状态
pub apps: UniversalProviderApps,
/// API 基础地址
#[serde(rename = "baseUrl")]
pub base_url: String,
/// API 密钥
#[serde(rename = "apiKey")]
pub api_key: String,
/// 各应用的模型配置
#[serde(default)]
pub models: UniversalProviderModels,
/// 网站链接
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
/// 备注信息
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// 图标名称
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 元数据
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 创建时间戳
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 排序索引
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
}
impl UniversalProvider {
/// 创建新的统一供应商
pub fn new(
id: String,
name: String,
provider_type: String,
base_url: String,
api_key: String,
) -> Self {
Self {
id,
name,
provider_type,
apps: UniversalProviderApps::default(),
base_url,
api_key,
models: UniversalProviderModels::default(),
website_url: None,
notes: None,
icon: None,
icon_color: None,
meta: None,
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
}
}
/// 生成 Claude 供应商配置
pub fn to_claude_provider(&self) -> Option<Provider> {
if !self.apps.claude {
return None;
}
let models = self.models.claude.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
let haiku = models
.and_then(|m| m.haiku_model.clone())
.unwrap_or_else(|| model.clone());
let sonnet = models
.and_then(|m| m.sonnet_model.clone())
.unwrap_or_else(|| model.clone());
let opus = models
.and_then(|m| m.opus_model.clone())
.unwrap_or_else(|| model.clone());
let settings_config = serde_json::json!({
"env": {
"ANTHROPIC_BASE_URL": self.base_url,
"ANTHROPIC_AUTH_TOKEN": self.api_key,
"ANTHROPIC_MODEL": model,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku,
"ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet,
"ANTHROPIC_DEFAULT_OPUS_MODEL": opus,
}
});
Some(Provider {
id: format!("universal-claude-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Codex 供应商配置
pub fn to_codex_provider(&self) -> Option<Provider> {
if !self.apps.codex {
return None;
}
let models = self.models.codex.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gpt-4o".to_string());
let reasoning_effort = models
.and_then(|m| m.reasoning_effort.clone())
.unwrap_or_else(|| "high".to_string());
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
let codex_base_url = if self.base_url.ends_with("/v1") {
self.base_url.clone()
} else {
format!("{}/v1", self.base_url.trim_end_matches('/'))
};
// 生成 Codex 的 config.toml 内容
let config_toml = format!(
r#"model_provider = "newapi"
model = "{}"
model_reasoning_effort = "{}"
disable_response_storage = true
[model_providers.newapi]
name = "NewAPI"
base_url = "{}"
wire_api = "responses"
requires_openai_auth = true"#,
model, reasoning_effort, codex_base_url
);
let settings_config = serde_json::json!({
"auth": {
"OPENAI_API_KEY": self.api_key
},
"config": config_toml
});
Some(Provider {
id: format!("universal-codex-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Gemini 供应商配置
pub fn to_gemini_provider(&self) -> Option<Provider> {
if !self.apps.gemini {
return None;
}
let models = self.models.gemini.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gemini-2.5-pro".to_string());
let settings_config = serde_json::json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": self.base_url,
"GEMINI_API_KEY": self.api_key,
"GEMINI_MODEL": model,
}
});
Some(Provider {
id: format!("universal-gemini-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
}
+22 -88
View File
@@ -17,16 +17,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::RwLock; use tokio::sync::RwLock;
pub struct ForwardResult {
pub response: Response,
pub provider: Provider,
}
pub struct ForwardError {
pub error: ProxyError,
pub provider: Option<Provider>,
}
pub struct RequestForwarder { pub struct RequestForwarder {
client: Client, client: Client,
/// 共享的 ProviderRouter(持有熔断器状态) /// 共享的 ProviderRouter(持有熔断器状态)
@@ -39,35 +29,24 @@ pub struct RequestForwarder {
failover_manager: Arc<FailoverSwitchManager>, failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘 /// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) /// 请求开始时的当前供应商 ID(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String, current_provider_id_at_start: String,
} }
impl RequestForwarder { impl RequestForwarder {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
router: Arc<ProviderRouter>, router: Arc<ProviderRouter>,
non_streaming_timeout: u64, timeout_secs: u64,
max_retries: u8, max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>, current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>, failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String, current_provider_id_at_start: String,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
) -> Self { ) -> Self {
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
// 参考 Claude Code Hub 的 undici 全局超时设计
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
let mut client_builder = Client::builder(); let mut client_builder = Client::builder();
if non_streaming_timeout > 0 { if timeout_secs > 0 {
// 使用配置的非流式超时 client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
} else {
// 禁用超时时使用全局超时作为保底
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
} }
let client = client_builder let client = client_builder
@@ -154,16 +133,13 @@ impl RequestForwarder {
body: Value, body: Value,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
providers: Vec<Provider>, providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> { ) -> Result<Response, ProxyError> {
// 获取适配器 // 获取适配器
let adapter = get_adapter(app_type); let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str(); let app_type_str = app_type.as_str();
if providers.is_empty() { if providers.is_empty() {
return Err(ForwardError { return Err(ProxyError::NoAvailableProvider);
error: ProxyError::NoAvailableProvider,
provider: None,
});
} }
log::info!( log::info!(
@@ -173,27 +149,16 @@ impl RequestForwarder {
); );
let mut last_error = None; let mut last_error = None;
let mut last_provider = None;
let mut attempted_providers = 0usize; let mut attempted_providers = 0usize;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
// 依次尝试每个供应商 // 依次尝试每个供应商
for provider in providers.iter() { for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求 let permit = self
let (allowed, used_half_open_permit) = if bypass_circuit_breaker { .router
(true, false) .allow_provider_request(&provider.id, app_type_str)
} else { .await;
let permit = self if !permit.allowed {
.router
.allow_provider_request(&provider.id, app_type_str)
.await;
(permit.allowed, permit.used_half_open_permit)
};
if !allowed {
log::debug!( log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过", "[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str, app_type_str,
@@ -202,6 +167,8 @@ impl RequestForwarder {
continue; continue;
} }
let used_half_open_permit = permit.used_half_open_permit;
attempted_providers += 1; attempted_providers += 1;
log::info!( log::info!(
@@ -301,10 +268,7 @@ impl RequestForwarder {
latency latency
); );
return Ok(ForwardResult { return Ok(response);
response,
provider: provider.clone(),
});
} }
Err(e) => { Err(e) => {
let latency = start.elapsed().as_millis() as u64; let latency = start.elapsed().as_millis() as u64;
@@ -345,7 +309,6 @@ impl RequestForwarder {
); );
last_error = Some(e); last_error = Some(e);
last_provider = Some(provider.clone());
// 继续尝试下一个供应商 // 继续尝试下一个供应商
continue; continue;
} }
@@ -367,10 +330,7 @@ impl RequestForwarder {
provider.name, provider.name,
e e
); );
return Err(ForwardError { return Err(e);
error: e,
provider: Some(provider.clone()),
});
} }
} }
} }
@@ -388,10 +348,7 @@ impl RequestForwarder {
(status.success_requests as f32 / status.total_requests as f32) * 100.0; (status.success_requests as f32 / status.total_requests as f32) * 100.0;
} }
} }
return Err(ForwardError { return Err(ProxyError::NoAvailableProvider);
error: ProxyError::NoAvailableProvider,
provider: None,
});
} }
// 所有供应商都失败了 // 所有供应商都失败了
@@ -411,10 +368,7 @@ impl RequestForwarder {
providers.len() providers.len()
); );
Err(ForwardError { Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider,
})
} }
/// 转发单个请求(使用适配器) /// 转发单个请求(使用适配器)
@@ -430,19 +384,12 @@ impl RequestForwarder {
let base_url = adapter.extract_base_url(provider)?; let base_url = adapter.extract_base_url(provider)?;
log::info!("[{}] base_url: {}", adapter.name(), base_url); log::info!("[{}] base_url: {}", adapter.name(), base_url);
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, endpoint);
// 检查是否需要格式转换 // 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider); let needs_transform = adapter.needs_transform(provider);
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
// 记录原始请求 JSON // 记录原始请求 JSON
log::info!( log::info!(
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}", "[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
@@ -450,23 +397,10 @@ impl RequestForwarder {
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string()) serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
); );
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
if let Some(ref mapped) = mapped_model {
log::info!(
"[{}] >>> 模型映射后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
);
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
}
// 转换请求体(如果需要) // 转换请求体(如果需要)
let request_body = if needs_transform { let request_body = if needs_transform {
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name()); log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
let transformed = adapter.transform_request(mapped_body, provider)?; let transformed = adapter.transform_request(body.clone(), provider)?;
log::info!( log::info!(
"[{}] >>> 转换后的请求 JSON:\n{}", "[{}] >>> 转换后的请求 JSON:\n{}",
adapter.name(), adapter.name(),
@@ -474,7 +408,7 @@ impl RequestForwarder {
); );
transformed transformed
} else { } else {
mapped_body body.clone()
}; };
log::info!( log::info!(
+4 -24
View File
@@ -31,26 +31,13 @@ pub struct UsageParserConfig {
// 模型提取器实现 // 模型提取器实现
// ============================================================================ // ============================================================================
/// Claude 流式响应模型提取(优先使用 usage.model /// Claude 流式响应模型提取(直接使用请求模型
fn claude_model_extractor(events: &[Value], request_model: &str) -> String { fn claude_model_extractor(_events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
request_model.to_string() request_model.to_string()
} }
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model /// OpenAI Chat Completions 流式响应模型提取
fn openai_model_extractor(events: &[Value], request_model: &str) -> String { fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从事件中直接提取
events events
.iter() .iter()
.find_map(|e| e.get("model")?.as_str()) .find_map(|e| e.get("model")?.as_str())
@@ -58,15 +45,8 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
.to_string() .to_string()
} }
/// Codex Responses API 流式响应模型提取(优先使用 usage.model /// Codex Responses API 流式响应模型提取
fn codex_model_extractor(events: &[Value], request_model: &str) -> String { fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从 response.completed 事件中提取
events events
.iter() .iter()
.find_map(|e| { .find_map(|e| {
+8 -35
View File
@@ -5,32 +5,23 @@
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::{ use crate::proxy::{
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError, forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError,
}; };
use std::time::Instant; use std::time::Instant;
/// 流式超时配置
#[derive(Debug, Clone, Copy)]
pub struct StreamingTimeoutConfig {
/// 首字节超时(秒),0 表示禁用
pub first_byte_timeout: u64,
/// 静默期超时(秒),0 表示禁用
pub idle_timeout: u64,
}
/// 请求上下文 /// 请求上下文
/// ///
/// 贯穿整个请求生命周期,包含: /// 贯穿整个请求生命周期,包含:
/// - 计时信息 /// - 计时信息
/// - 应用级代理配置per-app /// - 代理配置
/// - 选中的 Provider 列表(用于故障转移) /// - 选中的 Provider 列表(用于故障转移)
/// - 请求模型名称 /// - 请求模型名称
/// - 日志标签 /// - 日志标签
pub struct RequestContext { pub struct RequestContext {
/// 请求开始时间 /// 请求开始时间
pub start_time: Instant, pub start_time: Instant,
/// 应用级代理配置(per-app,包含重试次数和超时配置) /// 代理配置快照
pub app_config: AppProxyConfig, pub config: ProxyConfig,
/// 选中的 Provider(故障转移链的第一个) /// 选中的 Provider(故障转移链的第一个)
pub provider: Provider, pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移) /// 完整的 Provider 列表(用于故障转移)
@@ -71,14 +62,7 @@ impl RequestContext {
app_type_str: &'static str, app_type_str: &'static str,
) -> Result<Self, ProxyError> { ) -> Result<Self, ProxyError> {
let start_time = Instant::now(); let start_time = Instant::now();
let config = state.config.read().await.clone();
// 从数据库读取应用级代理配置(per-app)
let app_config = state
.db
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let current_provider_id = let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default(); crate::settings::get_current_provider(&app_type).unwrap_or_default();
@@ -112,7 +96,7 @@ impl RequestContext {
Ok(Self { Ok(Self {
start_time, start_time,
app_config, config,
provider, provider,
providers, providers,
current_provider_id, current_provider_id,
@@ -151,15 +135,13 @@ impl RequestContext {
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
RequestForwarder::new( RequestForwarder::new(
state.provider_router.clone(), state.provider_router.clone(),
self.app_config.non_streaming_timeout as u64, self.config.request_timeout,
self.app_config.max_retries as u8, self.config.max_retries,
state.status.clone(), state.status.clone(),
state.current_providers.clone(), state.current_providers.clone(),
state.failover_manager.clone(), state.failover_manager.clone(),
state.app_handle.clone(), state.app_handle.clone(),
self.current_provider_id.clone(), self.current_provider_id.clone(),
self.app_config.streaming_first_byte_timeout as u64,
self.app_config.streaming_idle_timeout as u64,
) )
} }
@@ -175,13 +157,4 @@ impl RequestContext {
pub fn latency_ms(&self) -> u64 { pub fn latency_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64 self.start_time.elapsed().as_millis() as u64
} }
/// 获取流式超时配置
#[inline]
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
StreamingTimeoutConfig {
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
idle_timeout: self.app_config.streaming_idle_timeout as u64,
}
}
} }
+39 -67
View File
@@ -5,7 +5,7 @@
//! 重构后的结构: //! 重构后的结构:
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块 //! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
//! - 各 handler 只保留独特的业务逻辑 //! - 各 handler 只保留独特的业务逻辑
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退 //! - Claude 的格式转换逻辑保留在此文件(独有功能
use super::{ use super::{
error_mapper::{get_error_message, map_proxy_error_to_status}, error_mapper::{get_error_message, map_proxy_error_to_status},
@@ -54,23 +54,34 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// 处理 /v1/messages 请求(Claude API /// 处理 /v1/messages 请求(Claude API
/// ///
/// Claude 处理器包含独特的格式转换逻辑: /// Claude 处理器包含独特的格式转换逻辑:
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic OpenAI 转换) /// - 当使用 OpenRouter 等中转服务时,需要将 Anthropic 格式转换为 OpenAI 格式
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退) /// - 响应需要从 OpenAI 格式转回 Anthropic 格式
pub async fn handle_messages( pub async fn handle_messages(
State(state): State<ProxyState>, State(state): State<ProxyState>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?; let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
let is_stream = body let is_stream = body
.get("stream") .get("stream")
.and_then(|s| s.as_bool()) .and_then(|s| s.as_bool())
.unwrap_or(false); .unwrap_or(false);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
// 转发请求 // 转发请求
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let result = match forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Claude, &AppType::Claude,
"/v1/messages", "/v1/messages",
@@ -80,30 +91,13 @@ pub async fn handle_messages(
) )
.await .await
{ {
Ok(result) => result, Ok(resp) => resp,
Err(mut err) => { Err(e) => {
if let Some(provider) = err.provider.take() { log_forward_error(&state, &ctx, is_stream, &e);
ctx.provider = provider; return Err(e);
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
let status = response.status(); let status = response.status();
log::info!("[Claude] 上游响应状态: {status}"); log::info!("[Claude] 上游响应状态: {status}");
@@ -118,7 +112,7 @@ pub async fn handle_messages(
/// Claude 格式转换处理(独有逻辑) /// Claude 格式转换处理(独有逻辑)
/// ///
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用) /// 处理 OpenRouter 等需要格式转换的中转服务
async fn handle_claude_transform( async fn handle_claude_transform(
response: reqwest::Response, response: reqwest::Response,
ctx: &RequestContext, ctx: &RequestContext,
@@ -170,14 +164,10 @@ async fn handle_claude_transform(
}) })
}; };
// 获取流式超时配置
let timeout_config = ctx.streaming_timeout_config();
let logged_stream = create_logged_passthrough_stream( let logged_stream = create_logged_passthrough_stream(
sse_stream, sse_stream,
"Claude/OpenRouter", "Claude/OpenRouter",
Some(usage_collector), Some(usage_collector),
timeout_config,
); );
let mut headers = axum::http::HeaderMap::new(); let mut headers = axum::http::HeaderMap::new();
@@ -305,7 +295,7 @@ pub async fn handle_chat_completions(
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -319,7 +309,7 @@ pub async fn handle_chat_completions(
); );
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let result = match forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/chat/completions", "/v1/chat/completions",
@@ -329,19 +319,13 @@ pub async fn handle_chat_completions(
) )
.await .await
{ {
Ok(result) => result, Ok(resp) => resp,
Err(mut err) => { Err(e) => {
if let Some(provider) = err.provider.take() { log_forward_error(&state, &ctx, is_stream, &e);
ctx.provider = provider; return Err(e);
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
@@ -353,7 +337,7 @@ pub async fn handle_responses(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -361,7 +345,7 @@ pub async fn handle_responses(
.unwrap_or(false); .unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let result = match forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/responses", "/v1/responses",
@@ -371,19 +355,13 @@ pub async fn handle_responses(
) )
.await .await
{ {
Ok(result) => result, Ok(resp) => resp,
Err(mut err) => { Err(e) => {
if let Some(provider) = err.provider.take() { log_forward_error(&state, &ctx, is_stream, &e);
ctx.provider = provider; return Err(e);
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
@@ -401,7 +379,7 @@ pub async fn handle_gemini(
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
// Gemini 的模型名称在 URI 中 // Gemini 的模型名称在 URI 中
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini") let ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
.await? .await?
.with_model_from_uri(&uri); .with_model_from_uri(&uri);
@@ -419,7 +397,7 @@ pub async fn handle_gemini(
.unwrap_or(false); .unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let result = match forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Gemini, &AppType::Gemini,
endpoint, endpoint,
@@ -429,19 +407,13 @@ pub async fn handle_gemini(
) )
.await .await
{ {
Ok(result) => result, Ok(resp) => resp,
Err(mut err) => { Err(e) => {
if let Some(provider) = err.provider.take() { log_forward_error(&state, &ctx, is_stream, &e);
ctx.provider = provider; return Err(e);
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
log::info!("[Gemini] 上游响应状态: {}", response.status()); log::info!("[Gemini] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
-1
View File
@@ -11,7 +11,6 @@ pub mod handler_config;
pub mod handler_context; pub mod handler_context;
mod handlers; mod handlers;
mod health; mod health;
pub mod model_mapper;
pub mod provider_router; pub mod provider_router;
pub mod providers; pub mod providers;
pub mod response_handler; pub mod response_handler;
-264
View File
@@ -1,264 +0,0 @@
//! 模型映射模块
//!
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
use crate::provider::Provider;
use serde_json::Value;
/// 模型映射配置
pub struct ModelMapping {
pub haiku_model: Option<String>,
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub default_model: Option<String>,
pub reasoning_model: Option<String>,
}
impl ModelMapping {
/// 从 Provider 配置中提取模型映射
pub fn from_provider(provider: &Provider) -> Self {
let env = provider.settings_config.get("env");
Self {
haiku_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
sonnet_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
opus_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
reasoning_model: env
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
}
}
/// 检查是否配置了任何模型映射
pub fn has_mapping(&self) -> bool {
self.haiku_model.is_some()
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.default_model.is_some()
}
/// 根据原始模型名称获取映射后的模型
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
let model_lower = original_model.to_lowercase();
// 1. thinking 模式优先使用推理模型
if has_thinking {
if let Some(ref m) = self.reasoning_model {
return m.clone();
}
}
// 2. 按模型类型匹配
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
}
}
if model_lower.contains("opus") {
if let Some(ref m) = self.opus_model {
return m.clone();
}
}
if model_lower.contains("sonnet") {
if let Some(ref m) = self.sonnet_model {
return m.clone();
}
}
// 3. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
}
// 4. 无映射,保持原样
original_model.to_string()
}
}
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
body.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled")
}
/// 对请求体应用模型映射
///
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
pub fn apply_model_mapping(
mut body: Value,
provider: &Provider,
) -> (Value, Option<String>, Option<String>) {
let mapping = ModelMapping::from_provider(provider);
// 如果没有配置映射,直接返回
if !mapping.has_mapping() {
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
return (body, original, None);
}
// 提取原始模型名
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
if let Some(ref original) = original_model {
let has_thinking = has_thinking_enabled(&body);
let mapped = mapping.map_model(original, has_thinking);
if mapped != *original {
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped));
}
}
(body, original_model, None)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider_with_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_provider_without_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_sonnet_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-sonnet-4-5-20250929"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_haiku_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-haiku-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "haiku-mapped");
assert_eq!(mapped, Some("haiku-mapped".to_string()));
}
#[test]
fn test_opus_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-opus-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "opus-mapped");
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_thinking_mode() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_disabled() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "disabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_unknown_model_uses_default() {
let provider = create_provider_with_mapping();
let body = json!({"model": "some-unknown-model"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "default-model");
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
let body = json!({"model": "claude-sonnet-4-5"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "claude-sonnet-4-5");
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
let body = json!({"model": "Claude-SONNET-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
}
+62 -144
View File
@@ -30,73 +30,74 @@ impl ProviderRouter {
/// 选择可用的供应商(支持故障转移) /// 选择可用的供应商(支持故障转移)
/// ///
/// 返回按优先级排序的可用供应商列表: /// 返回按优先级排序的可用供应商列表:
/// - 故障转移关闭时:仅返回当前供应商 /// 1. 当前供应商(is_current=true)始终第一位
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置 /// 2. 故障转移队列中的其他供应商(按 queue_order 排序)
/// 3. 只返回熔断器未打开的供应商
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> { pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new(); let mut result = Vec::new();
let all_providers = self.db.get_all_providers(app_type)?;
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) // 1. 当前供应商始终第一位
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { if let Some(current_id) = self.db.get_current_provider(app_type)? {
Ok(config) => { if let Some(current) = all_providers.get(&current_id) {
let enabled = config.auto_failover_enabled; let circuit_key = format!("{}:{}", app_type, current.id);
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}"); let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
enabled
if breaker.is_available().await {
log::info!(
"[{}] Current provider available: {} ({})",
app_type,
current.name,
current.id
);
result.push(current.clone());
} else {
log::warn!(
"[{}] Current provider {} circuit breaker open, checking failover queue",
app_type,
current.name
);
}
} }
Err(e) => { }
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" // 2. 获取故障转移队列中的供应商
); let queue = self.db.get_failover_queue(app_type)?;
false
for item in queue {
// 跳过已添加的当前供应商
if result.iter().any(|p| p.id == item.provider_id) {
continue;
} }
};
if auto_failover_enabled { // 跳过禁用的队列项
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序 if !item.enabled {
let failover_providers = self.db.get_failover_providers(app_type)?; continue;
log::info!( }
"[{}] Failover enabled, using queue order ({} items)",
app_type,
failover_providers.len()
);
for provider in failover_providers { // 获取供应商信息
if let Some(provider) = all_providers.get(&item.provider_id) {
// 检查熔断器状态 // 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id); let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if breaker.is_available().await { if breaker.is_available().await {
log::info!( log::info!(
"[{}] Queue provider available: {} ({}) at sort_index {:?}", "[{}] Failover provider available: {} ({}) at queue position {}",
app_type, app_type,
provider.name, provider.name,
provider.id, provider.id,
provider.sort_index item.queue_order
); );
result.push(provider); result.push(provider.clone());
} else { } else {
log::debug!( log::debug!(
"[{}] Queue provider {} circuit breaker open, skipping", "[{}] Failover provider {} circuit breaker open, skipping",
app_type, app_type,
provider.name provider.name
); );
} }
} }
} else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
log::info!(
"[{}] Current provider: {} ({})",
app_type,
current.name,
current.id
);
result.push(current);
}
}
} }
if result.is_empty() { if result.is_empty() {
@@ -106,7 +107,7 @@ impl ProviderRouter {
} }
log::info!( log::info!(
"[{}] Provider chain: {} provider(s) available", "[{}] Failover chain: {} provider(s) available",
app_type, app_type,
result.len() result.len()
); );
@@ -137,16 +138,9 @@ impl ProviderRouter {
success: bool, success: bool,
error_msg: Option<String>, error_msg: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用) // 1. 获取熔断器配置(用于更新健康状态和判断是否禁用)
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await { let config = self.db.get_circuit_breaker_config().await.ok();
Ok(app_config) => app_config.circuit_failure_threshold, let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5);
Err(e) => {
log::warn!(
"Failed to load circuit config for {app_type}, using default threshold: {e}"
);
5 // 默认值
}
};
// 2. 更新熔断器状态 // 2. 更新熔断器状态
let circuit_key = format!("{app_type}:{provider_id}"); let circuit_key = format!("{app_type}:{provider_id}");
@@ -243,34 +237,12 @@ impl ProviderRouter {
return breaker.clone(); return breaker.clone();
} }
// 从 key 中提取 app_type (格式: "app_type:provider_id") // 从数据库加载配置
let app_type = key.split(':').next().unwrap_or("claude"); let config = self
.db
// 按应用独立读取熔断器配置 .get_circuit_breaker_config()
let config = match self.db.get_proxy_config_for_app(app_type).await { .await
Ok(app_config) => { .unwrap_or_default();
log::debug!(
"Loading circuit breaker config for {key} (app={app_type}): \
failure_threshold={}, success_threshold={}, timeout={}s",
app_config.circuit_failure_threshold,
app_config.circuit_success_threshold,
app_config.circuit_timeout_seconds
);
crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
}
}
Err(e) => {
log::warn!(
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
);
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
}
};
log::debug!("Creating new circuit breaker for {key} with config: {config:?}"); log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
@@ -292,68 +264,16 @@ mod tests {
let db = Arc::new(Database::memory().unwrap()); let db = Arc::new(Database::memory().unwrap());
let router = ProviderRouter::new(db); let router = ProviderRouter::new(db);
// 测试创建熔断器
let breaker = router.get_or_create_circuit_breaker("claude:test").await; let breaker = router.get_or_create_circuit_breaker("claude:test").await;
assert!(breaker.allow_request().await.allowed); assert!(breaker.allow_request().await.allowed);
} }
#[tokio::test] #[tokio::test]
async fn test_failover_disabled_uses_current_provider() { async fn select_providers_does_not_consume_half_open_permit() {
let db = Arc::new(Database::memory().unwrap());
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].id, "a");
}
#[tokio::test]
async fn test_failover_enabled_uses_queue_order() {
let db = Arc::new(Database::memory().unwrap());
// 设置 sort_index 来控制顺序:b=1, a=2
let mut provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
provider_a.sort_index = Some(2);
let mut provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 按 sort_index 排序:b(1) 在前,a(2) 在后
assert_eq!(providers[0].id, "b");
assert_eq!(providers[1].id, "a");
}
#[tokio::test]
async fn test_select_providers_does_not_consume_half_open_permit() {
let db = Arc::new(Database::memory().unwrap()); let db = Arc::new(Database::memory().unwrap());
// 配置:让熔断器 Open 后立刻进入 HalfOpentimeout_seconds=0),并用 1 次失败就打开熔断器
db.update_circuit_breaker_config(&CircuitBreakerConfig { db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1, failure_threshold: 1,
timeout_seconds: 0, timeout_seconds: 0,
@@ -362,6 +282,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
// 准备 2 个 ProviderA(当前)+ B(队列)
let provider_a = let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None); Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b = let provider_b =
@@ -369,25 +290,22 @@ mod tests {
db.save_provider("claude", &provider_a).unwrap(); db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap(); db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap(); db.add_to_failover_queue("claude", "b").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone()); let router = ProviderRouter::new(db.clone());
// 让 B 进入 Open 状态(failure_threshold=1
router router
.record_result("b", "claude", false, false, Some("fail".to_string())) .record_result("b", "claude", false, false, Some("fail".to_string()))
.await .await
.unwrap(); .unwrap();
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
let providers = router.select_providers("claude").await.unwrap(); let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2); assert_eq!(providers.len(), 2);
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
assert!(router.allow_provider_request("b", "claude").await.allowed); assert!(router.allow_provider_request("b", "claude").await.allowed);
} }
} }
+1 -1
View File
@@ -87,7 +87,7 @@ pub trait ProviderAdapter: Send + Sync {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// 默认返回 `false`(透传模式)。 /// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。 /// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
/// ///
/// # Arguments /// # Arguments
/// * `provider` - Provider 配置 /// * `provider` - Provider 配置
+19 -73
View File
@@ -5,7 +5,7 @@
//! ## 认证模式 //! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用) //! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider; use crate::provider::Provider;
@@ -28,8 +28,10 @@ impl ClaudeAdapter {
/// - Claude: 默认 Anthropic 官方 /// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType { pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 OpenRouter // 检测 OpenRouter
if self.is_openrouter(provider) { if let Ok(base_url) = self.extract_base_url(provider) {
return ProviderType::OpenRouter; if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
} }
// 检测 ClaudeAuth (仅 Bearer 认证) // 检测 ClaudeAuth (仅 Bearer 认证)
@@ -48,24 +50,6 @@ impl ClaudeAdapter {
false false
} }
/// 检测 OpenRouter 是否启用兼容模式
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
if !self.is_openrouter(provider) {
return false;
}
let raw = provider.settings_config.get("openrouter_compat_mode");
match raw {
Some(serde_json::Value::Bool(enabled)) => *enabled,
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
Some(serde_json::Value::String(value)) => {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
_ => true,
}
}
/// 检测是否为仅 Bearer 认证模式 /// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool { fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode // 检查 settings_config 中的 auth_mode
@@ -103,14 +87,6 @@ impl ClaudeAdapter {
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
return Some(key.to_string()); return Some(key.to_string());
} }
if let Some(key) = env
.get("ANTHROPIC_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
return Some(key.to_string());
}
// OpenRouter key // OpenRouter key
if let Some(key) = env if let Some(key) = env
.get("OPENROUTER_API_KEY") .get("OPENROUTER_API_KEY")
@@ -210,13 +186,12 @@ impl ProviderAdapter for ClaudeAdapter {
} }
fn build_url(&self, base_url: &str, endpoint: &str) -> String { fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// NOTE: // OpenRouter 使用 /v1/chat/completions
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages` if base_url.contains("openrouter.ai") {
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
// }
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
// Anthropic 直连
format!( format!(
"{}/{}", "{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
@@ -232,24 +207,19 @@ impl ProviderAdapter for ClaudeAdapter {
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01"), .header("anthropic-version", "2023-06-01"),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key // ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => request AuthStrategy::ClaudeAuth => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
// OpenRouter: Bearer // OpenRouter: Bearer
AuthStrategy::Bearer => request AuthStrategy::Bearer => {
.header("Authorization", format!("Bearer {}", auth.api_key)) request.header("Authorization", format!("Bearer {}", auth.api_key))
.header("anthropic-version", "2023-06-01"), }
_ => request, _ => request,
} }
} }
fn needs_transform(&self, _provider: &Provider) -> bool { fn needs_transform(&self, provider: &Provider) -> bool {
// NOTE: self.is_openrouter(provider)
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
// Anthropic ↔ OpenAI 的格式转换。
//
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
self.is_openrouter_compat_enabled(_provider)
} }
fn transform_request( fn transform_request(
@@ -283,7 +253,6 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
@@ -315,21 +284,6 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::Anthropic); assert_eq!(auth.strategy, AuthStrategy::Anthropic);
} }
#[test]
fn test_extract_auth_anthropic_api_key() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_API_KEY": "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] #[test]
fn test_extract_auth_openrouter() { fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
@@ -424,7 +378,7 @@ mod tests {
fn test_build_url_openrouter() { fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages"); assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
} }
#[test] #[test]
@@ -444,13 +398,5 @@ mod tests {
} }
})); }));
assert!(adapter.needs_transform(&openrouter_provider)); assert!(adapter.needs_transform(&openrouter_provider));
let openrouter_disabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": false
}));
assert!(!adapter.needs_transform(&openrouter_disabled));
} }
} }
-1
View File
@@ -174,7 +174,6 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
-1
View File
@@ -250,7 +250,6 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
+4 -9
View File
@@ -48,21 +48,17 @@ pub enum ProviderType {
Gemini, Gemini,
/// Google Gemini CLI (OAuth Bearer) /// Google Gemini CLI (OAuth Bearer)
GeminiCli, GeminiCli,
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用) /// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
OpenRouter, OpenRouter,
} }
impl ProviderType { impl ProviderType {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式 /// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
#[allow(dead_code)] #[allow(dead_code)]
pub fn needs_transform(&self) -> bool { pub fn needs_transform(&self) -> bool {
match self { matches!(self, ProviderType::OpenRouter)
ProviderType::OpenRouter => false,
_ => false,
}
} }
/// 获取默认端点 /// 获取默认端点
@@ -209,7 +205,6 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
@@ -220,7 +215,7 @@ mod tests {
assert!(!ProviderType::Codex.needs_transform()); assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform()); assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform()); assert!(!ProviderType::GeminiCli.needs_transform());
assert!(!ProviderType::OpenRouter.needs_transform()); assert!(ProviderType::OpenRouter.needs_transform());
} }
#[test] #[test]
@@ -394,7 +394,6 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
+14 -69
View File
@@ -3,11 +3,8 @@
//! 统一处理流式和非流式 API 响应 //! 统一处理流式和非流式 API 响应
use super::{ use super::{
handler_config::UsageParserConfig, handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState,
handler_context::{RequestContext, StreamingTimeoutConfig}, usage::parser::TokenUsage, ProxyError,
server::ProxyState,
usage::parser::TokenUsage,
ProxyError,
}; };
use axum::response::Response; use axum::response::Response;
use bytes::Bytes; use bytes::Bytes;
@@ -20,7 +17,6 @@ use std::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Arc,
}, },
time::Duration,
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -64,12 +60,8 @@ pub async fn handle_streaming(
// 创建使用量收集器 // 创建使用量收集器
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config); let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
// 获取流式超时配置 // 创建带日志的透传流
let timeout_config = ctx.streaming_timeout_config(); let logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector));
// 创建带日志和超时的透传流
let logged_stream =
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
let body = axum::body::Body::from_stream(logged_stream); let body = axum::body::Body::from_stream(logged_stream);
builder.body(body).unwrap() builder.body(body).unwrap()
@@ -101,16 +93,12 @@ pub async fn handle_non_streaming(
// 解析使用量 // 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) { if let Some(usage) = (parser_config.response_parser)(&json_value) {
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型 let model = json_value
let model = if let Some(ref m) = usage.model { .get("model")
m.clone() .and_then(|m| m.as_str())
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) { .unwrap_or(&ctx.request_model);
m.to_string()
} else {
ctx.request_model.clone()
};
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false); spawn_log_usage(state, ctx, usage, model, status.as_u16(), false);
} else { } else {
log::debug!( log::debug!(
"[{}] 未能解析 usage 信息,跳过记录", "[{}] 未能解析 usage 信息,跳过记录",
@@ -356,60 +344,21 @@ async fn log_usage_internal(
} }
} }
/// 创建带日志记录和超时控制的透传流 /// 创建带日志记录的透传流
pub fn create_logged_passthrough_stream( pub fn create_logged_passthrough_stream(
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static, stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
tag: &'static str, tag: &'static str,
usage_collector: Option<SseUsageCollector>, usage_collector: Option<SseUsageCollector>,
timeout_config: StreamingTimeoutConfig,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send { ) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! { async_stream::stream! {
let mut buffer = String::new(); let mut buffer = String::new();
let mut collector = usage_collector; let mut collector = usage_collector;
let mut is_first_chunk = true;
// 超时配置
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
Some(Duration::from_secs(timeout_config.first_byte_timeout))
} else {
None
};
let idle_timeout = if timeout_config.idle_timeout > 0 {
Some(Duration::from_secs(timeout_config.idle_timeout))
} else {
None
};
tokio::pin!(stream); tokio::pin!(stream);
loop { while let Some(chunk) = stream.next().await {
// 选择超时时间:首字节超时或静默期超时 match chunk {
let timeout_duration = if is_first_chunk { Ok(bytes) => {
first_byte_timeout
} else {
idle_timeout
};
let chunk_result = match timeout_duration {
Some(duration) => {
match tokio::time::timeout(duration, stream.next()).await {
Ok(Some(chunk)) => Some(chunk),
Ok(None) => None, // 流结束
Err(_) => {
// 超时
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
break;
}
}
}
None => stream.next().await, // 无超时限制
};
match chunk_result {
Some(Ok(bytes)) => {
is_first_chunk = false;
let text = String::from_utf8_lossy(&bytes); let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text); buffer.push_str(&text);
@@ -445,15 +394,11 @@ pub fn create_logged_passthrough_stream(
yield Ok(bytes); yield Ok(bytes);
} }
Some(Err(e)) => { Err(e) => {
log::error!("[{tag}] 流错误: {e}"); log::error!("[{tag}] 流错误: {e}");
yield Err(std::io::Error::other(e.to_string())); yield Err(std::io::Error::other(e.to_string()));
break; break;
} }
None => {
// 流正常结束
break;
}
} }
} }
+4 -69
View File
@@ -3,54 +3,33 @@ use serde::{Deserialize, Serialize};
/// 代理服务器配置 /// 代理服务器配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig { pub struct ProxyConfig {
/// 是否启用代理服务
pub enabled: bool,
/// 监听地址 /// 监听地址
pub listen_address: String, pub listen_address: String,
/// 监听端口 /// 监听端口
pub listen_port: u16, pub listen_port: u16,
/// 最大重试次数 /// 最大重试次数
pub max_retries: u8, pub max_retries: u8,
/// 请求超时时间(秒)- 已废弃,保留兼容 /// 请求超时时间(秒)
pub request_timeout: u64, pub request_timeout: u64,
/// 是否启用日志 /// 是否启用日志
pub enable_logging: bool, pub enable_logging: bool,
/// 是否正在接管 Live 配置 /// 是否正在接管 Live 配置
#[serde(default)] #[serde(default)]
pub live_takeover_active: bool, pub live_takeover_active: bool,
/// 流式首字超时(秒)- 等待首个数据块的最大时间
#[serde(default = "default_streaming_first_byte_timeout")]
pub streaming_first_byte_timeout: u64,
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
#[serde(default = "default_streaming_idle_timeout")]
pub streaming_idle_timeout: u64,
/// 非流式总超时(秒)- 非流式请求的总超时时间
#[serde(default = "default_non_streaming_timeout")]
pub non_streaming_timeout: u64,
}
fn default_streaming_first_byte_timeout() -> u64 {
30
}
fn default_streaming_idle_timeout() -> u64 {
60
}
fn default_non_streaming_timeout() -> u64 {
600
} }
impl Default for ProxyConfig { impl Default for ProxyConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false,
listen_address: "127.0.0.1".to_string(), listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口 listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3, max_retries: 3,
request_timeout: 300, request_timeout: 300,
enable_logging: true, enable_logging: true,
live_takeover_active: false, live_takeover_active: false,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 600,
} }
} }
} }
@@ -147,47 +126,3 @@ pub struct LiveBackup {
/// 备份时间 /// 备份时间
pub backed_up_at: String, pub backed_up_at: String,
} }
/// 全局代理配置(统一字段,三行镜像)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalProxyConfig {
/// 代理总开关
pub proxy_enabled: bool,
/// 监听地址
pub listen_address: String,
/// 监听端口
pub listen_port: u16,
/// 是否启用日志
pub enable_logging: bool,
}
/// 应用级代理配置(每个 app 独立)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppProxyConfig {
/// 应用类型 (claude/codex/gemini)
pub app_type: String,
/// 该 app 代理启用开关
pub enabled: bool,
/// 该 app 自动故障转移开关
pub auto_failover_enabled: bool,
/// 最大重试次数
pub max_retries: u32,
/// 流式首字超时(秒)
pub streaming_first_byte_timeout: u32,
/// 流式静默超时(秒)
pub streaming_idle_timeout: u32,
/// 非流式总超时(秒)
pub non_streaming_timeout: u32,
/// 熔断失败阈值
pub circuit_failure_threshold: u32,
/// 熔断恢复阈值
pub circuit_success_threshold: u32,
/// 熔断恢复等待时间(秒)
pub circuit_timeout_seconds: u32,
/// 错误率阈值
pub circuit_error_rate_threshold: f64,
/// 计算错误率的最小请求数
pub circuit_min_requests: u32,
}
+3 -87
View File
@@ -34,12 +34,6 @@ impl TokenUsage {
/// 从 Claude API 非流式响应解析 /// 从 Claude API 非流式响应解析
pub fn from_claude_response(body: &Value) -> Option<Self> { pub fn from_claude_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?; let usage = body.get("usage")?;
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: usage.get("input_tokens")?.as_u64()? as u32, input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
output_tokens: usage.get("output_tokens")?.as_u64()? as u32, output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
@@ -51,7 +45,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model, model: None,
}) })
} }
@@ -59,20 +53,11 @@ impl TokenUsage {
#[allow(dead_code)] #[allow(dead_code)]
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> { pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
let mut usage = Self::default(); let mut usage = Self::default();
let mut model: Option<String> = None;
for event in events { for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
match event_type { match event_type {
"message_start" => { "message_start" => {
// 从 message_start 提取模型名称
if model.is_none() {
if let Some(message) = event.get("message") {
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
model = Some(m.to_string());
}
}
}
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) { if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
// 从 message_start 获取 input_tokens(原生 Claude API // 从 message_start 获取 input_tokens(原生 Claude API
if let Some(input) = if let Some(input) =
@@ -117,7 +102,6 @@ impl TokenUsage {
} }
if usage.input_tokens > 0 || usage.output_tokens > 0 { if usage.input_tokens > 0 || usage.output_tokens > 0 {
usage.model = model;
Some(usage) Some(usage)
} else { } else {
None None
@@ -157,12 +141,6 @@ impl TokenUsage {
return None; return None;
} }
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: input_tokens? as u32, input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32, output_tokens: output_tokens? as u32,
@@ -174,7 +152,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model, model: None,
}) })
} }
@@ -244,18 +222,12 @@ impl TokenUsage {
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: prompt_tokens as u32, input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32, output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens, cache_read_tokens: cached_tokens,
cache_creation_tokens: 0, cache_creation_tokens: 0,
model, model: None,
}) })
} }
@@ -350,7 +322,6 @@ mod tests {
#[test] #[test]
fn test_claude_response_parsing() { fn test_claude_response_parsing() {
let response = json!({ let response = json!({
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 100, "input_tokens": 100,
"output_tokens": 50, "output_tokens": 50,
@@ -364,26 +335,6 @@ mod tests {
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10); assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_claude_response_parsing_no_model() {
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);
assert_eq!(usage.model, None);
} }
#[test] #[test]
@@ -392,7 +343,6 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 100, "input_tokens": 100,
"cache_read_input_tokens": 20, "cache_read_input_tokens": 20,
@@ -413,36 +363,6 @@ mod tests {
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10); assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_claude_stream_parsing_no_model() {
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);
assert_eq!(usage.model, None);
} }
#[test] #[test]
@@ -561,7 +481,6 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 0, "input_tokens": 0,
"output_tokens": 0 "output_tokens": 0
@@ -583,7 +502,6 @@ mod tests {
let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 150); assert_eq!(usage.input_tokens, 150);
assert_eq!(usage.output_tokens, 75); assert_eq!(usage.output_tokens, 75);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
} }
#[test] #[test]
@@ -594,7 +512,6 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 200, "input_tokens": 200,
"cache_read_input_tokens": 50 "cache_read_input_tokens": 50
@@ -613,6 +530,5 @@ mod tests {
assert_eq!(usage.input_tokens, 200); assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.output_tokens, 100); assert_eq!(usage.output_tokens, 100);
assert_eq!(usage.cache_read_tokens, 50); assert_eq!(usage.cache_read_tokens, 50);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
} }
} }
+1 -3
View File
@@ -146,9 +146,7 @@ impl ConfigService {
let cfg_text = settings.get("config").and_then(Value::as_str); let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用 crate::mcp::sync_enabled_to_codex(config)?;
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) { if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
-137
View File
@@ -690,140 +690,3 @@ pub struct ProviderSortUpdate {
#[serde(rename = "sortIndex")] #[serde(rename = "sortIndex")]
pub sort_index: usize, pub sort_index: usize,
} }
// ============================================================================
// 统一供应商(Universal Provider)服务方法
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
impl ProviderService {
/// 获取所有统一供应商
pub fn list_universal(
state: &AppState,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
state.db.get_all_universal_providers()
}
/// 获取单个统一供应商
pub fn get_universal(
state: &AppState,
id: &str,
) -> Result<Option<UniversalProvider>, AppError> {
state.db.get_universal_provider(id)
}
/// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps
pub fn upsert_universal(
state: &AppState,
provider: UniversalProvider,
) -> Result<bool, AppError> {
// 保存统一供应商
state.db.save_universal_provider(&provider)?;
Ok(true)
}
/// 删除统一供应商
pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {
// 获取统一供应商(用于删除生成的子供应商)
let provider = state.db.get_universal_provider(id)?;
// 删除统一供应商
state.db.delete_universal_provider(id)?;
// 删除生成的子供应商
if let Some(p) = provider {
if p.apps.claude {
let claude_id = format!("universal-claude-{}", id);
let _ = state.db.delete_provider("claude", &claude_id);
}
if p.apps.codex {
let codex_id = format!("universal-codex-{}", id);
let _ = state.db.delete_provider("codex", &codex_id);
}
if p.apps.gemini {
let gemini_id = format!("universal-gemini-{}", id);
let _ = state.db.delete_provider("gemini", &gemini_id);
}
}
Ok(true)
}
/// 同步统一供应商到各应用
pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {
let provider = state
.db
.get_universal_provider(id)?
.ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?;
// 同步到 Claude
if let Some(mut claude_provider) = provider.to_claude_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &claude_provider.settings_config);
claude_provider.settings_config = merged;
}
state.db.save_provider("claude", &claude_provider)?;
} else {
// 如果禁用了 Claude,删除对应的子供应商
let claude_id = format!("universal-claude-{}", id);
let _ = state.db.delete_provider("claude", &claude_id);
}
// 同步到 Codex
if let Some(mut codex_provider) = provider.to_codex_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &codex_provider.settings_config);
codex_provider.settings_config = merged;
}
state.db.save_provider("codex", &codex_provider)?;
} else {
let codex_id = format!("universal-codex-{}", id);
let _ = state.db.delete_provider("codex", &codex_id);
}
// 同步到 Gemini
if let Some(mut gemini_provider) = provider.to_gemini_provider() {
// 合并已有配置
if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? {
let mut merged = existing.settings_config.clone();
Self::merge_json(&mut merged, &gemini_provider.settings_config);
gemini_provider.settings_config = merged;
}
state.db.save_provider("gemini", &gemini_provider)?;
} else {
let gemini_id = format!("universal-gemini-{}", id);
let _ = state.db.delete_provider("gemini", &gemini_id);
}
Ok(true)
}
/// 递归合并 JSONbase 为底,patch 覆盖同名字段
fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {
use serde_json::Value;
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
for (k, v_patch) in patch_map {
match base_map.get_mut(k) {
Some(v_base) => Self::merge_json(v_base, v_patch),
None => {
base_map.insert(k.clone(), v_patch.clone());
}
}
}
}
// 其它类型:直接覆盖
(base_val, patch_val) => {
*base_val = patch_val.clone();
}
}
}
}
+132 -603
View File
@@ -8,7 +8,6 @@ use crate::database::Database;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::server::ProxyServer; use crate::proxy::server::ProxyServer;
use crate::proxy::types::*; use crate::proxy::types::*;
use crate::services::provider::write_live_snapshot;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
@@ -42,31 +41,31 @@ impl ProxyService {
} }
/// 启动代理服务器 /// 启动代理服务器
pub async fn start(&self) -> Result<ProxyServerInfo, String> { ///
// 1. 启动时自动设置 proxy_enabled = true /// - `persist_enabled = true`:将 `proxy_config.enabled` 持久化为启用(用于“总开关”)
let mut global_config = self /// - `persist_enabled = false`:仅在当前进程启动代理服务(用于“按 App 接管”自动启动)
.db pub async fn start(&self, persist_enabled: bool) -> Result<ProxyServerInfo, String> {
.get_global_proxy_config() // 1. 获取配置
.await let mut config = self
.map_err(|e| format!("获取全局代理配置失败: {e}"))?;
if !global_config.proxy_enabled {
global_config.proxy_enabled = true;
self.db
.update_global_proxy_config(global_config.clone())
.await
.map_err(|e| format!("更新代理总开关失败: {e}"))?;
}
// 2. 获取配置
let config = self
.db .db
.get_proxy_config() .get_proxy_config()
.await .await
.map_err(|e| format!("获取代理配置失败: {e}"))?; .map_err(|e| format!("获取代理配置失败: {e}"))?;
// 2. 仅在需要时持久化 enabled(避免“按 App 接管”自动启动时误打开总开关)
if persist_enabled {
config.enabled = true;
}
// 3. 若已在运行:确保持久化状态(如需要)并返回当前信息 // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息
if let Some(server) = self.server.read().await.as_ref() { if let Some(server) = self.server.read().await.as_ref() {
if persist_enabled {
self.db
.update_proxy_config(config)
.await
.map_err(|e| format!("保存代理配置失败: {e}"))?;
}
let status = server.get_status().await; let status = server.get_status().await;
return Ok(ProxyServerInfo { return Ok(ProxyServerInfo {
address: status.address, address: status.address,
@@ -87,6 +86,14 @@ impl ProxyService {
// 5. 保存服务器实例 // 5. 保存服务器实例
*self.server.write().await = Some(server); *self.server.write().await = Some(server);
// 6. 持久化 enabled 状态(仅总开关)
if persist_enabled {
self.db
.update_proxy_config(config)
.await
.map_err(|e| format!("保存代理配置失败: {e}"))?;
}
log::info!("代理服务器已启动: {}:{}", info.address, info.port); log::info!("代理服务器已启动: {}:{}", info.address, info.port);
Ok(info) Ok(info)
} }
@@ -131,7 +138,7 @@ impl ProxyService {
} }
// 5. 启动代理服务器 // 5. 启动代理服务器
match self.start().await { match self.start(true).await {
Ok(info) => Ok(info), Ok(info) => Ok(info),
Err(e) => { Err(e) => {
// 启动失败,恢复原始配置 // 启动失败,恢复原始配置
@@ -152,55 +159,54 @@ impl ProxyService {
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理) /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> { pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
// 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测 let claude = self
let claude_enabled = self
.db .db
.get_proxy_config_for_app("claude") .get_live_backup("claude")
.await .await
.map(|c| c.enabled) .map_err(|e| format!("获取 Claude 接管状态失败: {e}"))?
.unwrap_or(false); .is_some();
let codex_enabled = self let codex = self
.db .db
.get_proxy_config_for_app("codex") .get_live_backup("codex")
.await .await
.map(|c| c.enabled) .map_err(|e| format!("获取 Codex 接管状态失败: {e}"))?
.unwrap_or(false); .is_some();
let gemini_enabled = self let gemini = self
.db .db
.get_proxy_config_for_app("gemini") .get_live_backup("gemini")
.await .await
.map(|c| c.enabled) .map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))?
.unwrap_or(false); .is_some();
Ok(ProxyTakeoverStatus { Ok(ProxyTakeoverStatus {
claude: claude_enabled, claude,
codex: codex_enabled, codex,
gemini: gemini_enabled, gemini,
}) })
} }
/// 为指定应用开启/关闭 Live 接管 /// 为指定应用开启/关闭 Live 接管
/// ///
/// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置 /// - 开启:自动启动代理服务(不影响总开关持久化),仅接管当前 app 的 Live 配置
/// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务 /// - 关闭:仅恢复当前 app 的 Live 配置;若总开关未开启且无其它接管,则自动停止代理服务
pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> { pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {
let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let app_type_str = app.as_str(); let app_type_str = app.as_str();
if enabled { if enabled {
// 1) 代理服务未运行则自动启动 // 1) 代理服务未运行则自动启动(不持久化总开关)
if !self.is_running().await { if !self.is_running().await {
self.start().await?; self.start(false).await?;
} }
// 2) 已接管则直接返回(幂等) // 2) 已接管则直接返回(幂等)
let current_config = self if self
.db .db
.get_proxy_config_for_app(app_type_str) .get_live_backup(app_type_str)
.await .await
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
.is_some()
if current_config.enabled { {
return Ok(()); return Ok(());
} }
@@ -216,46 +222,25 @@ impl ProxyService {
// 5) 写入接管配置(仅当前 app) // 5) 写入接管配置(仅当前 app)
if let Err(e) = self.takeover_live_config_strict(&app).await { if let Err(e) = self.takeover_live_config_strict(&app).await {
log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}");
match self.restore_live_config_for_app(&app).await { let _ = self.restore_live_config_for_app(&app).await;
Ok(()) => { let _ = self.db.delete_live_backup(app_type_str).await;
// 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源
let _ = self.db.delete_live_backup(app_type_str).await;
}
Err(restore_err) => {
log::error!(
"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}"
);
}
}
return Err(e); return Err(e);
} }
// 6) 设置 proxy_config.enabled = true // 6) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
let mut updated_config = self
.db
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
updated_config.enabled = true;
self.db
.update_proxy_config_for_app(updated_config)
.await
.map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}"))?;
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
let _ = self.db.set_live_takeover_active(true).await; let _ = self.db.set_live_takeover_active(true).await;
return Ok(()); return Ok(());
} }
// 关闭接管:检查 enabled 状态 // 关闭接管:无备份则视为未接管(幂等)
let current_config = self let has_backup = self
.db .db
.get_proxy_config_for_app(app_type_str) .get_live_backup(app_type_str)
.await .await
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
.is_some();
if !current_config.enabled { if !has_backup {
return Ok(()); // 未接管,幂等返回 return Ok(());
} }
// 1) 恢复 Live 配置 // 1) 恢复 Live 配置
@@ -267,36 +252,22 @@ impl ProxyService {
.await .await
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
// 3) 设置 proxy_config.enabled = false // 3) 若无其它接管,更新旧标志,并在总开关未开启时停止代理服务
let mut updated_config = self let has_any_backup = self
.db .db
.get_proxy_config_for_app(app_type_str) .has_any_live_backup()
.await .await
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; .map_err(|e| format!("检查 Live 备份失败: {e}"))?;
updated_config.enabled = false; if !has_any_backup {
self.db
.update_proxy_config_for_app(updated_config)
.await
.map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?;
// 4) 清除该应用的健康状态(关闭代理时重置队列状态)
self.db
.clear_provider_health_for_app(app_type_str)
.await
.map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?;
// 5) 若无其它接管,更新旧标志,并停止代理服务
// 检查是否还有其它 app 的 enabled = true
let any_enabled = self
.db
.is_live_takeover_active()
.await
.map_err(|e| format!("检查接管状态失败: {e}"))?;
if !any_enabled {
let _ = self.db.set_live_takeover_active(false).await; let _ = self.db.set_live_takeover_active(false).await;
if self.is_running().await { let master_enabled = self
.db
.get_proxy_config()
.await
.map_err(|e| format!("获取代理配置失败: {e}"))?
.enabled;
if !master_enabled && self.is_running().await {
// 此时没有任何 app 处于接管状态,停止服务即可 // 此时没有任何 app 处于接管状态,停止服务即可
let _ = self.stop().await; let _ = self.stop().await;
} }
@@ -360,35 +331,34 @@ impl ProxyService {
match env_obj { match env_obj {
Some(obj) => { Some(obj) => {
obj.insert(token_key.to_string(), json!(token));
// ANTHROPIC_AUTH_TOKEN 与 ANTHROPIC_API_KEY 视为同义字段,保持一致
if token_key == "ANTHROPIC_AUTH_TOKEN" if token_key == "ANTHROPIC_AUTH_TOKEN"
|| token_key == "ANTHROPIC_API_KEY" || token_key == "ANTHROPIC_API_KEY"
{ {
let mut updated = false; obj.insert(
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") { "ANTHROPIC_AUTH_TOKEN".to_string(),
obj.insert( json!(token),
"ANTHROPIC_AUTH_TOKEN".to_string(), );
json!(token), obj.insert(
); "ANTHROPIC_API_KEY".to_string(),
updated = true; json!(token),
} );
if obj.contains_key("ANTHROPIC_API_KEY") {
obj.insert(
"ANTHROPIC_API_KEY".to_string(),
json!(token),
);
updated = true;
}
if !updated {
obj.insert(token_key.to_string(), json!(token));
}
} else {
obj.insert(token_key.to_string(), json!(token));
} }
} }
None => { None => {
// 至少写入一份可用的 Token // 至少写入一份可用的 Token
provider.settings_config["env"] = provider.settings_config["env"] = json!({
json!({ token_key: token }); token_key: token
});
if token_key == "ANTHROPIC_AUTH_TOKEN"
|| token_key == "ANTHROPIC_API_KEY"
{
provider.settings_config["env"]
["ANTHROPIC_AUTH_TOKEN"] = json!(token);
provider.settings_config["env"]["ANTHROPIC_API_KEY"] =
json!(token);
}
} }
} }
@@ -525,18 +495,10 @@ impl ProxyService {
.await .await
.map_err(|e| format!("停止代理服务器失败: {e}"))?; .map_err(|e| format!("停止代理服务器失败: {e}"))?;
// 停止时设置 proxy_enabled = false // enabled 设为 false,避免下次启动时自动开启
let mut global_config = self if let Ok(mut config) = self.db.get_proxy_config().await {
.db config.enabled = false;
.get_global_proxy_config() let _ = self.db.update_proxy_config(config).await;
.await
.map_err(|e| format!("获取全局代理配置失败: {e}"))?;
if global_config.proxy_enabled {
global_config.proxy_enabled = false;
if let Err(e) = self.db.update_global_proxy_config(global_config).await {
log::warn!("更新代理总开关失败: {e}");
}
} }
log::info!("代理服务器已停止"); log::info!("代理服务器已停止");
@@ -546,88 +508,47 @@ impl ProxyService {
} }
} }
/// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用 /// 停止代理服务器(恢复 Live 配置)
///
/// 会清除 settings 表中的代理状态,下次启动不会自动恢复。
pub async fn stop_with_restore(&self) -> Result<(), String> { pub async fn stop_with_restore(&self) -> Result<(), String> {
// 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)
if let Err(e) = self.stop().await { if let Err(e) = self.stop().await {
log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}");
// stop() 只有在 server 实例存在时才会把 enabled 设为 false
// 这里兜底确保“总开关关闭”能落盘关闭状态。
if let Ok(mut config) = self.db.get_proxy_config().await {
if config.enabled {
config.enabled = false;
let _ = self.db.update_proxy_config(config).await;
}
}
} }
// 2. 恢复原始 Live 配置 // 2. 恢复原始 Live 配置
self.restore_live_configs().await?; self.restore_live_configs().await?;
// 3. 清除 proxy_config 表中的接管状态(兼容旧版) // 3. 清除接管状态
self.db self.db
.set_live_takeover_active(false) .set_live_takeover_active(false)
.await .await
.map_err(|e| format!("清除接管状态失败: {e}"))?; .map_err(|e| format!("清除接管状态失败: {e}"))?;
// 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复) // 4. 删除备份
for app_type in ["claude", "codex", "gemini"] {
if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
config.enabled = false;
if let Err(e) = self.db.update_proxy_config_for_app(config).await {
log::warn!("清除 {app_type} enabled 状态失败: {e}");
}
}
}
}
// 5. 删除备份
self.db self.db
.delete_all_live_backups() .delete_all_live_backups()
.await .await
.map_err(|e| format!("删除备份失败: {e}"))?; .map_err(|e| format!("删除备份失败: {e}"))?;
// 6. 重置健康状态(让健康徽章恢复为正常) // 5. 重置健康状态(让健康徽章恢复为正常)
self.db self.db
.clear_all_provider_health() .clear_all_provider_health()
.await .await
.map_err(|e| format!("重置健康状态失败: {e}"))?; .map_err(|e| format!("重置健康状态失败: {e}"))?;
// 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用
log::info!("代理已停止,Live 配置已恢复"); log::info!("代理已停止,Live 配置已恢复");
Ok(()) Ok(())
} }
/// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)
///
/// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复
pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {
// 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)
if let Err(e) = self.stop().await {
log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}");
}
// 2. 恢复原始 Live 配置
self.restore_live_configs().await?;
// 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)
// 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复
if let Ok(mut config) = self.db.get_proxy_config().await {
config.live_takeover_active = false;
let _ = self.db.update_proxy_config(config).await;
}
// 4. 删除备份(Live 配置已恢复,备份不再需要)
self.db
.delete_all_live_backups()
.await
.map_err(|e| format!("删除备份失败: {e}"))?;
// 5. 重置健康状态
self.db
.clear_all_provider_health()
.await
.map_err(|e| format!("重置健康状态失败: {e}"))?;
log::info!("代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)");
Ok(())
}
/// 备份各应用的 Live 配置 /// 备份各应用的 Live 配置
async fn backup_live_configs(&self) -> Result<(), String> { async fn backup_live_configs(&self) -> Result<(), String> {
// Claude // Claude
@@ -988,270 +909,30 @@ impl ProxyService {
/// 恢复原始 Live 配置 /// 恢复原始 Live 配置
async fn restore_live_configs(&self) -> Result<(), String> { async fn restore_live_configs(&self) -> Result<(), String> {
let mut errors = Vec::new(); // Claude
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = self
.restore_live_config_for_app_with_fallback(&app_type)
.await
{
errors.push(e);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join(""))
}
}
async fn restore_live_config_for_app_with_fallback(
&self,
app_type: &AppType,
) -> Result<(), String> {
let app_type_str = app_type.as_str();
// 1) 优先从 Live 备份恢复(这是“原始 Live”的唯一可靠来源)
let backup = self
.db
.get_live_backup(app_type_str)
.await
.map_err(|e| format!("获取 {app_type_str} Live 备份失败: {e}"))?;
if let Some(backup) = backup {
let config: Value = serde_json::from_str(&backup.original_config) let config: Value = serde_json::from_str(&backup.original_config)
.map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?; .map_err(|e| format!("解析 Claude 备份失败: {e}"))?;
self.write_live_config_for_app(app_type, &config)?; self.write_claude_live(&config)?;
log::info!("{app_type_str} Live 配置已从备份恢复"); log::info!("Claude Live 配置已恢复");
return Ok(());
} }
// 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景) // Codex
if !self.detect_takeover_in_live_config_for_app(app_type) { if let Ok(Some(backup)) = self.db.get_live_backup("codex").await {
return Ok(()); let config: Value = serde_json::from_str(&backup.original_config)
.map_err(|e| format!("解析 Codex 备份失败: {e}"))?;
self.write_codex_live(&config)?;
log::info!("Codex Live 配置已恢复");
} }
// 2.1) 优先从 SSOT(当前供应商)重建 Live(比“清理字段”更可用) // Gemini
match self.restore_live_from_ssot_for_app(app_type) { if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await {
Ok(true) => { let config: Value = serde_json::from_str(&backup.original_config)
log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)"); .map_err(|e| format!("解析 Gemini 备份失败: {e}"))?;
return Ok(()); self.write_gemini_live(&config)?;
} log::info!("Gemini Live 配置已恢复");
Ok(false) => {
log::warn!(
"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符"
);
}
Err(e) => {
log::error!(
"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}"
);
}
} }
// 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态
self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;
log::info!("{app_type_str} Live 接管占位符已清理(无备份兜底)");
Ok(())
}
fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {
match app_type {
AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config),
}
}
fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
match app_type {
AppType::Claude => match self.read_claude_live() {
Ok(config) => Self::is_claude_live_taken_over(&config),
Err(_) => false,
},
AppType::Codex => match self.read_codex_live() {
Ok(config) => Self::is_codex_live_taken_over(&config),
Err(_) => false,
},
AppType::Gemini => match self.read_gemini_live() {
Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false,
},
}
}
/// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。
///
/// 返回值:
/// - Ok(true):已成功写回
/// - Ok(false):缺少当前供应商/供应商不存在,无法写回
fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {
let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)
.map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?;
let Some(current_id) = current_id else {
return Ok(false);
};
let providers = self
.db
.get_all_providers(app_type.as_str())
.map_err(|e| format!("读取 {app_type:?} 供应商列表失败: {e}"))?;
let Some(provider) = providers.get(&current_id) else {
return Ok(false);
};
write_live_snapshot(app_type, provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
Ok(true)
}
fn cleanup_takeover_placeholders_in_live_for_app(
&self,
app_type: &AppType,
) -> Result<(), String> {
match app_type {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
}
}
fn is_local_proxy_url(url: &str) -> bool {
let url = url.trim();
if !url.starts_with("http://") {
return false;
}
let rest = &url["http://".len()..];
rest.starts_with("127.0.0.1")
|| rest.starts_with("localhost")
|| rest.starts_with("0.0.0.0")
|| rest.starts_with("[::1]")
|| rest.starts_with("[::]")
|| rest.starts_with("::1")
|| rest.starts_with("::")
}
fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {
let mut config = self.read_claude_live()?;
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
return Ok(());
};
for key in [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
] {
if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {
env.remove(key);
}
}
if env
.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
.map(Self::is_local_proxy_url)
.unwrap_or(false)
{
env.remove("ANTHROPIC_BASE_URL");
}
self.write_claude_live(&config)?;
Ok(())
}
fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {
let mut config = self.read_codex_live()?;
if let Some(auth) = config.get_mut("auth").and_then(|v| v.as_object_mut()) {
if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)
{
auth.remove("OPENAI_API_KEY");
}
}
if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) {
let updated = Self::remove_local_toml_base_url(cfg_str);
config["config"] = json!(updated);
}
self.write_codex_live(&config)?;
Ok(())
}
fn remove_local_toml_base_url(toml_str: &str) -> String {
use toml_edit::DocumentMut;
let mut doc = match toml_str.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(_) => return toml_str.to_string(),
};
let model_provider = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::to_string);
if let Some(provider_key) = model_provider {
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|v| v.as_table_mut())
{
if let Some(provider_table) = model_providers
.get_mut(provider_key.as_str())
.and_then(|v| v.as_table_mut())
{
let should_remove = provider_table
.get("base_url")
.and_then(|item| item.as_str())
.map(Self::is_local_proxy_url)
.unwrap_or(false);
if should_remove {
provider_table.remove("base_url");
}
}
}
}
// 兜底:清理顶层 base_url(仅当它看起来像本地代理地址)
let should_remove_root = doc
.get("base_url")
.and_then(|item| item.as_str())
.map(Self::is_local_proxy_url)
.unwrap_or(false);
if should_remove_root {
doc.as_table_mut().remove("base_url");
}
doc.to_string()
}
fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {
let mut config = self.read_gemini_live()?;
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
return Ok(());
};
if env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {
env.remove("GEMINI_API_KEY");
}
if env
.get("GOOGLE_GEMINI_BASE_URL")
.and_then(|v| v.as_str())
.map(Self::is_local_proxy_url)
.unwrap_or(false)
{
env.remove("GOOGLE_GEMINI_BASE_URL");
}
self.write_gemini_live(&config)?;
Ok(()) Ok(())
} }
@@ -1265,7 +946,7 @@ impl ProxyService {
/// 从异常退出中恢复(启动时调用) /// 从异常退出中恢复(启动时调用)
/// ///
/// 检测到 Live 备份残留时调用此方法。 /// 检测到 live_takeover_active=true 但代理未运行时调用此方法。
/// 会恢复 Live 配置、清除接管标志、删除备份。 /// 会恢复 Live 配置、清除接管标志、删除备份。
pub async fn recover_from_crash(&self) -> Result<(), String> { pub async fn recover_from_crash(&self) -> Result<(), String> {
// 1. 恢复 Live 配置 // 1. 恢复 Live 配置
@@ -1289,7 +970,7 @@ impl ProxyService {
/// 检测 Live 配置是否处于“被接管”的残留状态 /// 检测 Live 配置是否处于“被接管”的残留状态
/// ///
/// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时, /// 用于兜底处理:当数据库标志未写入成功(或旧版本遗留)但 Live 文件已经写成代理占位符时,
/// 启动流程可以据此触发恢复逻辑。 /// 启动流程可以据此触发恢复逻辑。
pub fn detect_takeover_in_live_configs(&self) -> bool { pub fn detect_takeover_in_live_configs(&self) -> bool {
if let Ok(config) = self.read_claude_live() { if let Ok(config) = self.read_claude_live() {
@@ -1574,8 +1255,9 @@ impl ProxyService {
.await .await
.map_err(|e| format!("获取代理配置失败: {e}"))?; .map_err(|e| format!("获取代理配置失败: {e}"))?;
// 保存到数据库(保持 live_takeover_active 状态不变) // 保存到数据库(保持 enabled 和 live_takeover_active 状态不变)
let mut new_config = config.clone(); let mut new_config = config.clone();
new_config.enabled = previous.enabled;
new_config.live_takeover_active = previous.live_takeover_active; new_config.live_takeover_active = previous.live_takeover_active;
self.db self.db
@@ -1688,47 +1370,6 @@ impl ProxyService {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
struct TempHome {
#[allow(dead_code)]
dir: TempDir,
original_home: Option<String>,
original_userprofile: Option<String>,
}
impl TempHome {
fn new() -> Self {
let dir = TempDir::new().expect("failed to create temp home");
let original_home = env::var("HOME").ok();
let original_userprofile = env::var("USERPROFILE").ok();
env::set_var("HOME", dir.path());
env::set_var("USERPROFILE", dir.path());
Self {
dir,
original_home,
original_userprofile,
}
}
}
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_home {
Some(value) => env::set_var("HOME", value),
None => env::remove_var("HOME"),
}
match &self.original_userprofile {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
}
}
#[test] #[test]
fn update_toml_base_url_updates_active_model_provider_base_url() { fn update_toml_base_url_updates_active_model_provider_base_url() {
@@ -1791,116 +1432,4 @@ model = "gpt-5.1-codex"
assert_eq!(base_url, new_url); assert_eq!(base_url, new_url);
} }
#[tokio::test]
#[serial]
async fn sync_claude_token_does_not_add_anthropic_api_key() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "stale"
}
}),
None,
);
db.save_provider("claude", &provider)
.expect("save provider");
db.set_current_provider("claude", "p1")
.expect("set current provider");
let live_config = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "fresh"
}
});
service
.sync_live_config_to_provider(&AppType::Claude, &live_config)
.await
.expect("sync");
let updated = db
.get_provider_by_id("p1", "claude")
.expect("get provider")
.expect("provider exists");
let env = updated
.settings_config
.get("env")
.and_then(|v| v.as_object())
.expect("env object");
assert_eq!(
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
Some("fresh")
);
assert!(
!env.contains_key("ANTHROPIC_API_KEY"),
"should not add ANTHROPIC_API_KEY when absent"
);
}
#[tokio::test]
#[serial]
async fn sync_claude_token_respects_existing_api_key_field() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_API_KEY": "stale"
}
}),
None,
);
db.save_provider("claude", &provider)
.expect("save provider");
db.set_current_provider("claude", "p1")
.expect("set current provider");
let live_config = json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "fresh"
}
});
service
.sync_live_config_to_provider(&AppType::Claude, &live_config)
.await
.expect("sync");
let updated = db
.get_provider_by_id("p1", "claude")
.expect("get provider")
.expect("provider exists");
let env = updated
.settings_config
.get("env")
.and_then(|v| v.as_object())
.expect("env object");
assert_eq!(
env.get("ANTHROPIC_API_KEY").and_then(|v| v.as_str()),
Some("fresh")
);
assert!(
!env.contains_key("ANTHROPIC_AUTH_TOKEN"),
"should not add ANTHROPIC_AUTH_TOKEN when absent"
);
}
} }
+3 -48
View File
@@ -3,7 +3,6 @@
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。 //! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
use futures::StreamExt; use futures::StreamExt;
use regex::Regex;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -142,17 +141,15 @@ impl StreamCheckService {
.build() .build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?; .map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let result = match app_type { let result = match app_type {
AppType::Claude => { AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await
} }
AppType::Codex => { AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await
} }
AppType::Gemini => { AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await
} }
}; };
@@ -382,48 +379,6 @@ impl StreamCheckService {
AppError::Message(e.to_string()) AppError::Message(e.to_string())
} }
} }
fn resolve_test_model(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> String {
match app_type {
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone()),
AppType::Codex => {
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
}
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.unwrap_or_else(|| config.gemini_model.clone()),
}
}
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
provider
.settings_config
.get("env")
.and_then(|env| env.get(key))
.and_then(|value| value.as_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn extract_codex_model(provider: &Provider) -> Option<String> {
let config_text = provider
.settings_config
.get("config")
.and_then(|value| value.as_str())?;
if config_text.trim().is_empty() {
return None;
}
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
re.captures(config_text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty())
}
} }
#[cfg(test)] #[cfg(test)]
+14 -30
View File
@@ -4,7 +4,7 @@
use crate::database::{lock_conn, Database}; use crate::database::{lock_conn, Database};
use crate::error::AppError; use crate::error::AppError;
use chrono::{Duration, Local, TimeZone}; use chrono::{Duration, Utc};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -186,17 +186,8 @@ impl Database {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
if days <= 1 { if days <= 1 {
let today = Local::now().date_naive();
let start_of_today = today.and_hms_opt(0, 0, 0).unwrap();
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减一天
let start_ts = Local
.from_local_datetime(&start_of_today)
.earliest()
.unwrap_or_else(|| Local::now() - Duration::days(1))
.timestamp();
let sql = "SELECT let sql = "SELECT
strftime('%Y-%m-%dT%H:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket, strftime('%Y-%m-%dT%H:00:00Z', datetime(created_at, 'unixepoch')) as bucket,
COUNT(*) as request_count, COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
@@ -205,12 +196,12 @@ impl Database {
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs FROM proxy_request_logs
WHERE created_at >= ? WHERE created_at >= strftime('%s', 'now', '-1 day')
GROUP BY bucket GROUP BY bucket
ORDER BY bucket ASC"; ORDER BY bucket ASC";
let mut stmt = conn.prepare(sql)?; let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([start_ts], |row| { let rows = stmt.query_map([], |row| {
Ok(DailyStats { Ok(DailyStats {
date: row.get(0)?, date: row.get(0)?,
request_count: row.get::<_, i64>(1)? as u64, request_count: row.get::<_, i64>(1)? as u64,
@@ -230,11 +221,12 @@ impl Database {
} }
let mut stats = Vec::new(); let mut stats = Vec::new();
let today = Utc::now().date_naive();
for hour in 0..24 { for hour in 0..24 {
let bucket = today let bucket = today
.and_hms_opt(hour, 0, 0) .and_hms_opt(hour, 0, 0)
.unwrap() .unwrap()
.format("%Y-%m-%dT%H:00:00") .format("%Y-%m-%dT%H:00:00Z")
.to_string(); .to_string();
if let Some(stat) = buckets.remove(&bucket) { if let Some(stat) = buckets.remove(&bucket) {
@@ -254,18 +246,8 @@ impl Database {
} }
Ok(stats) Ok(stats)
} else { } else {
let today = Local::now().date_naive();
let start_day = today - Duration::days((days.saturating_sub(1)) as i64);
let start_of_window = start_day.and_hms_opt(0, 0, 0).unwrap();
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减 days 天
let start_ts = Local
.from_local_datetime(&start_of_window)
.earliest()
.unwrap_or_else(|| Local::now() - Duration::days(days as i64))
.timestamp();
let sql = "SELECT let sql = "SELECT
strftime('%Y-%m-%dT00:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket, date(created_at, 'unixepoch') as bucket,
COUNT(*) as request_count, COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
@@ -274,12 +256,12 @@ impl Database {
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs FROM proxy_request_logs
WHERE created_at >= ? WHERE created_at >= strftime('%s', 'now', ?)
GROUP BY bucket GROUP BY bucket
ORDER BY bucket ASC"; ORDER BY bucket ASC";
let mut stmt = conn.prepare(sql)?; let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([start_ts], |row| { let rows = stmt.query_map([format!("-{days} days")], |row| {
Ok(DailyStats { Ok(DailyStats {
date: row.get(0)?, date: row.get(0)?,
request_count: row.get::<_, i64>(1)? as u64, request_count: row.get::<_, i64>(1)? as u64,
@@ -299,10 +281,12 @@ impl Database {
} }
let mut stats = Vec::new(); let mut stats = Vec::new();
let start_day =
Utc::now().date_naive() - Duration::days((days.saturating_sub(1)) as i64);
for i in 0..days { for i in 0..days {
let day = start_day + Duration::days(i as i64); let day = start_day + Duration::days(i as i64);
let key = day.format("%Y-%m-%dT00:00:00").to_string(); let key = day.format("%Y-%m-%d").to_string();
if let Some(stat) = map.remove(&key) { if let Some(stat) = map.remove(&key) {
stats.push(stat); stats.push(stat);
} else { } else {
@@ -633,7 +617,7 @@ impl Database {
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
FROM proxy_request_logs FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ? WHERE provider_id = ? AND app_type = ?
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')", AND date(created_at, 'unixepoch') = date('now')",
params![provider_id, app_type], params![provider_id, app_type],
|row| row.get(0), |row| row.get(0),
) )
@@ -645,7 +629,7 @@ impl Database {
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
FROM proxy_request_logs FROM proxy_request_logs
WHERE provider_id = ? AND app_type = ? WHERE provider_id = ? AND app_type = ?
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')", AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')",
params![provider_id, app_type], params![provider_id, app_type],
|row| row.get(0), |row| row.get(0),
) )
-8
View File
@@ -31,9 +31,6 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动 /// 是否启用 Claude 插件联动
#[serde(default)] #[serde(default)]
pub enable_claude_plugin_integration: bool, pub enable_claude_plugin_integration: bool,
/// 是否跳过 Claude Code 初次安装确认
#[serde(default = "default_true")]
pub skip_claude_onboarding: bool,
/// 是否开机自启 /// 是否开机自启
#[serde(default)] #[serde(default)]
pub launch_on_startup: bool, pub launch_on_startup: bool,
@@ -68,17 +65,12 @@ fn default_minimize_to_tray_on_close() -> bool {
true true
} }
fn default_true() -> bool {
true
}
impl Default for AppSettings { impl Default for AppSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
show_in_tray: true, show_in_tray: true,
minimize_to_tray_on_close: true, minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false, enable_claude_plugin_integration: false,
skip_claude_onboarding: true,
launch_on_startup: false, launch_on_startup: false,
language: None, language: None,
claude_config_dir: None, claude_config_dir: None,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.9.0-3", "version": "3.9.0-1",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+1 -3
View File
@@ -4,9 +4,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"titleBarStyle": "Visible", "titleBarStyle": "Visible"
"minWidth": 900,
"minHeight": 600
} }
] ]
} }
+15 -78
View File
@@ -76,8 +76,19 @@ fn sync_codex_provider_writes_auth_and_config() {
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发 // 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入 config.mcp.codex.servers.insert(
"echo-server".into(),
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo",
"args": ["hello"]
}
}),
);
let provider_config = json!({ let provider_config = json!({
"auth": { "auth": {
@@ -122,10 +133,9 @@ fn sync_codex_provider_writes_auth_and_config() {
); );
let toml_text = fs::read_to_string(&config_path).expect("read config.toml"); let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
// 验证基础配置正确写入
assert!( assert!(
toml_text.contains("base_url"), toml_text.contains("command = \"echo\""),
"config.toml should contain base_url from provider config" "config.toml should contain serialized enabled MCP server"
); );
// 当前供应商应同步最新 config 文本 // 当前供应商应同步最新 config 文本
@@ -993,76 +1003,3 @@ fn export_sql_returns_error_for_invalid_path() {
other => panic!("expected IoContext or Io error, got {other:?}"), other => panic!("expected IoContext or Io error, got {other:?}"),
} }
} }
#[test]
fn import_sql_rejects_non_cc_switch_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let import_path = home.join("not-cc-switch.sql");
fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql");
let err = state
.db
.import_sql(&import_path)
.expect_err("non-cc-switch sql should be rejected");
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "backup.sql.invalid_format");
}
other => panic!("expected Localized error, got {other:?}"),
}
}
#[test]
fn import_sql_accepts_cc_switch_exported_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Create a database with some data and export it.
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "test-provider".to_string();
manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let export_path = home.join("cc-switch-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Reset database, then import into a fresh one.
reset_test_fs();
let state = create_test_state().expect("create test state");
state
.db
.import_sql(&export_path)
.expect("import should succeed");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("load providers");
assert!(
providers.contains_key("test-provider"),
"imported providers should contain test-provider"
);
}
-2
View File
@@ -49,7 +49,6 @@ pub fn test_mutex() -> &'static Mutex<()> {
} }
/// 创建测试用的 AppState,包含一个空的数据库 /// 创建测试用的 AppState,包含一个空的数据库
#[allow(dead_code)]
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> { pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
let db = Arc::new(Database::init()?); let db = Arc::new(Database::init()?);
let proxy_service = ProxyService::new(db.clone()); let proxy_service = ProxyService::new(db.clone());
@@ -57,7 +56,6 @@ pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
} }
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据 /// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
#[allow(dead_code)]
pub fn create_test_state_with_config( pub fn create_test_state_with_config(
config: &MultiAppConfig, config: &MultiAppConfig,
) -> Result<AppState, Box<dyn std::error::Error>> { ) -> Result<AppState, Box<dyn std::error::Error>> {
+113 -240
View File
@@ -1,9 +1,7 @@
import { useEffect, useMemo, useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner"; import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useQueryClient } from "@tanstack/react-query";
import { import {
Plus, Plus,
Settings, Settings,
@@ -26,7 +24,6 @@ import {
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions"; import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useLastValidValue } from "@/hooks/useLastValidValue";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher"; import { AppSwitcher } from "@/components/AppSwitcher";
@@ -44,25 +41,12 @@ import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage"; import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
type View = type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
| "providers"
| "settings"
| "prompts"
| "skills"
| "mcp"
| "agents"
| "universal";
const DRAG_BAR_HEIGHT = 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
function App() { function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>("claude"); const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers"); const [currentView, setCurrentView] = useState<View>("providers");
@@ -74,10 +58,6 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]); const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false); const [showEnvBanner, setShowEnvBanner] = useState(false);
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
const promptPanelRef = useRef<any>(null); const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null); const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null); const skillsPageRef = useRef<any>(null);
@@ -85,20 +65,15 @@ function App() {
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
// 获取代理服务状态 // 获取代理服务状态
const { const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus();
isRunning: isProxyRunning,
takeoverStatus,
status: proxyStatus,
} = useProxyStatus();
// 当前应用的代理是否开启 // 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取) // 任意应用代理是否开启
const activeProviderId = useMemo(() => { const isTakeoverActive =
const target = proxyStatus?.active_targets?.find( takeoverStatus?.claude ||
(t) => t.app_type === activeApp, takeoverStatus?.codex ||
); takeoverStatus?.gemini ||
return target?.provider_id; false;
}, [proxyStatus?.active_targets, activeApp]);
// 获取供应商列表,当代理服务运行时自动刷新 // 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, { const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
@@ -142,38 +117,6 @@ function App() {
}; };
}, [activeApp, refetch]); }, [activeApp, refetch]);
// 监听统一供应商同步事件,刷新所有应用的供应商列表
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setupListener = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
unsubscribe = await listen("universal-provider-synced", async () => {
// 统一供应商同步后刷新所有应用的供应商列表
// 使用 invalidateQueries 使所有 providers 查询失效
await queryClient.invalidateQueries({ queryKey: ["providers"] });
// 同时更新托盘菜单
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to update tray menu", error);
}
});
} catch (error) {
console.error(
"[App] Failed to subscribe universal-provider-synced event",
error,
);
}
};
setupListener();
return () => {
unsubscribe?.();
};
}, [queryClient]);
// 应用启动时检测所有应用的环境变量冲突 // 应用启动时检测所有应用的环境变量冲突
useEffect(() => { useEffect(() => {
const checkEnvOnStartup = async () => { const checkEnvOnStartup = async () => {
@@ -251,21 +194,6 @@ function App() {
checkEnvOnSwitch(); checkEnvOnSwitch();
}, [activeApp]); }, [activeApp]);
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
return;
}
event.preventDefault();
setCurrentView("settings");
};
window.addEventListener("keydown", handleGlobalShortcut);
return () => {
window.removeEventListener("keydown", handleGlobalShortcut);
};
}, []);
// 打开网站链接 // 打开网站链接
const handleOpenWebsite = async (url: string) => { const handleOpenWebsite = async (url: string) => {
try { try {
@@ -348,20 +276,7 @@ function App() {
// 导入配置成功后刷新 // 导入配置成功后刷新
const handleImportSuccess = async () => { const handleImportSuccess = async () => {
try { await refetch();
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
});
await queryClient.refetchQueries({
queryKey: ["providers"],
type: "all",
});
} catch (error) {
console.error("[App] Failed to refresh providers after import", error);
await refetch();
}
try { try {
await providersApi.updateTrayMenu(); await providersApi.updateTrayMenu();
} catch (error) { } catch (error) {
@@ -370,115 +285,79 @@ function App() {
}; };
const renderContent = () => { const renderContent = () => {
const content = (() => { switch (currentView) {
switch (currentView) { case "settings":
case "settings": return (
return ( <SettingsPage
<SettingsPage open={true}
open={true} onOpenChange={() => setCurrentView("providers")}
onOpenChange={() => setCurrentView("providers")} onImportSuccess={handleImportSuccess}
onImportSuccess={handleImportSuccess} />
/> );
); case "prompts":
case "prompts": return (
return ( <PromptPanel
<PromptPanel ref={promptPanelRef}
ref={promptPanelRef} open={true}
open={true} onOpenChange={() => setCurrentView("providers")}
onOpenChange={() => setCurrentView("providers")} appId={activeApp}
appId={activeApp} />
/> );
); case "skills":
case "skills": return (
return ( <SkillsPage
<SkillsPage ref={skillsPageRef}
ref={skillsPageRef} onClose={() => setCurrentView("providers")}
onClose={() => setCurrentView("providers")} initialApp={activeApp}
initialApp={activeApp} />
/> );
); case "mcp":
case "mcp": return (
return ( <UnifiedMcpPanel
<UnifiedMcpPanel ref={mcpPanelRef}
ref={mcpPanelRef} onOpenChange={() => setCurrentView("providers")}
onOpenChange={() => setCurrentView("providers")} />
/> );
); case "agents":
case "agents": return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
return ( default:
<AgentsPanel onOpenChange={() => setCurrentView("providers")} /> return (
); <div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
case "universal": {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
return ( <div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<div className="mx-auto max-w-[56rem] px-5 pt-4"> <div className="space-y-4">
<UniversalProviderPanel /> <ProviderList
</div> providers={providers}
); currentProviderId={currentProviderId}
default: appId={activeApp}
return ( isLoading={isLoading}
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden"> isProxyRunning={isProxyRunning}
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */} isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1"> onSwitch={switchProvider}
<AnimatePresence mode="wait"> onEdit={setEditingProvider}
<motion.div onDelete={setConfirmDelete}
key={activeApp} onDuplicate={handleDuplicateProvider}
initial={{ opacity: 0 }} onConfigureUsage={setUsageProvider}
animate={{ opacity: 1 }} onOpenWebsite={handleOpenWebsite}
exit={{ opacity: 0 }} onCreate={() => setIsAddOpen(true)}
transition={{ duration: 0.15 }} />
className="space-y-4"
>
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
isProxyTakeover={
isProxyRunning && isCurrentAppTakeoverActive
}
activeProviderId={activeProviderId}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</motion.div>
</AnimatePresence>
</div> </div>
</div> </div>
); </div>
} );
})(); }
return (
<AnimatePresence mode="wait">
<motion.div
key={currentView}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{content}
</motion.div>
</AnimatePresence>
);
}; };
return ( return (
<div <div
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} style={{ overflowX: "hidden" }}
> >
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */} {/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
<div <div
className="fixed top-0 left-0 right-0 z-[60]" className="fixed top-0 left-0 right-0 h-4 z-[60]"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any} style={{ WebkitAppRegion: "drag" } as any}
/> />
{/* 环境变量警告横幅 */} {/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && ( {showEnvBanner && envConflicts.length > 0 && (
@@ -508,18 +387,13 @@ function App() {
)} )}
<header <header
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md" className="fixed top-0 z-50 w-full py-3 bg-background/80 backdrop-blur-md transition-all duration-300"
data-tauri-drag-region data-tauri-drag-region
style={ style={{ WebkitAppRegion: "drag" } as any}
{
WebkitAppRegion: "drag",
top: DRAG_BAR_HEIGHT,
height: HEADER_HEIGHT,
} as any
}
> >
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
<div <div
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6" className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any} style={{ WebkitAppRegion: "drag" } as any}
> >
@@ -535,7 +409,7 @@ function App() {
onClick={() => setCurrentView("providers")} onClick={() => setCurrentView("providers")}
className="mr-2 rounded-lg" className="mr-2 rounded-lg"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
{currentView === "settings" && t("settings.title")} {currentView === "settings" && t("settings.title")}
@@ -544,10 +418,6 @@ function App() {
{currentView === "skills" && t("skills.title")} {currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")} {currentView === "agents" && t("agents.title")}
{currentView === "universal" &&
t("universalProvider.title", {
defaultValue: "统一供应商",
})}
</h1> </h1>
</div> </div>
) : ( ) : (
@@ -573,7 +443,7 @@ function App() {
title={t("common.settings")} title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<Settings className="w-4 h-4" /> <Settings className="h-4 w-4" />
</Button> </Button>
</div> </div>
<UpdateBadge onClick={() => setCurrentView("settings")} /> <UpdateBadge onClick={() => setCurrentView("settings")} />
@@ -582,27 +452,27 @@ function App() {
</div> </div>
<div <div
className="flex items-center gap-2 h-[32px]" className="flex items-center gap-2"
style={{ WebkitAppRegion: "no-drag" } as any} style={{ WebkitAppRegion: "no-drag" } as any}
> >
{currentView === "prompts" && ( {currentView === "prompts" && (
<Button <Button
size="icon" size="icon"
onClick={() => promptPanelRef.current?.openAdd()} onClick={() => promptPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`} className={addActionButtonClass}
title={t("prompts.add")} title={t("prompts.add")}
> >
<Plus className="w-5 h-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
)} )}
{currentView === "mcp" && ( {currentView === "mcp" && (
<Button <Button
size="icon" size="icon"
onClick={() => mcpPanelRef.current?.openAdd()} onClick={() => mcpPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`} className={addActionButtonClass}
title={t("mcp.unifiedPanel.addServer")} title={t("mcp.unifiedPanel.addServer")}
> >
<Plus className="w-5 h-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
)} )}
{currentView === "skills" && ( {currentView === "skills" && (
@@ -613,7 +483,7 @@ function App() {
onClick={() => skillsPageRef.current?.refresh()} onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
{t("skills.refresh")} {t("skills.refresh")}
</Button> </Button>
<Button <Button
@@ -622,7 +492,7 @@ function App() {
onClick={() => skillsPageRef.current?.openRepoManager()} onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<Settings className="w-4 h-4 mr-2" /> <Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")} {t("skills.repoManager")}
</Button> </Button>
</> </>
@@ -633,7 +503,7 @@ function App() {
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} /> <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl"> <div className="bg-muted p-1 rounded-xl flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -647,20 +517,20 @@ function App() {
)} )}
title={t("skills.manage")} title={t("skills.manage")}
> >
<Wrench className="flex-shrink-0 w-4 h-4" /> <Wrench className="h-4 w-4 flex-shrink-0" />
</Button> </Button>
{/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && ( {/* {isClaudeApp && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setCurrentView("agents")} onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents" title="Agents"
> >
<Bot className="w-4 h-4" /> <Bot className="h-4 w-4" />
</Button> </Button>
)} */} )} */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -668,7 +538,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")} title={t("prompts.manage")}
> >
<Book className="w-4 h-4" /> <Book className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -677,7 +547,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")} title={t("mcp.title")}
> >
<Server className="w-4 h-4" /> <Server className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -686,7 +556,7 @@ function App() {
size="icon" size="icon"
className={`ml-2 ${addActionButtonClass}`} className={`ml-2 ${addActionButtonClass}`}
> >
<Plus className="w-5 h-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
</> </>
)} )}
@@ -694,8 +564,13 @@ function App() {
</div> </div>
</header> </header>
<main className="flex-1 pb-12 animate-fade-in "> <main
<div className="pb-12">{renderContent()}</div> className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
</main> </main>
<AddProviderDialog <AddProviderDialog
@@ -707,7 +582,7 @@ function App() {
<EditProviderDialog <EditProviderDialog
open={Boolean(editingProvider)} open={Boolean(editingProvider)}
provider={effectiveEditingProvider} provider={editingProvider}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setEditingProvider(null); setEditingProvider(null);
@@ -715,19 +590,17 @@ function App() {
}} }}
onSubmit={handleEditProvider} onSubmit={handleEditProvider}
appId={activeApp} appId={activeApp}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} isProxyTakeover={isProxyRunning && isTakeoverActive}
/> />
{effectiveUsageProvider && ( {usageProvider && (
<UsageScriptModal <UsageScriptModal
provider={effectiveUsageProvider} provider={usageProvider}
appId={activeApp} appId={activeApp}
isOpen={Boolean(usageProvider)} isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)} onClose={() => setUsageProvider(null)}
onSave={(script) => { onSave={(script) => {
if (usageProvider) { void saveUsageScript(usageProvider, script);
void saveUsageScript(usageProvider, script);
}
}} }}
/> />
)} )}
+4 -4
View File
@@ -24,11 +24,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
}; };
return ( return (
<div className="inline-flex bg-muted rounded-xl p-1 gap-1"> <div className="inline-flex bg-muted rounded-lg p-1 gap-1">
<button <button
type="button" type="button"
onClick={() => handleSwitch("claude")} onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude" activeApp === "claude"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -50,7 +50,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("codex")} onClick={() => handleSwitch("codex")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex" activeApp === "codex"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -72,7 +72,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("gemini")} onClick={() => handleSwitch("gemini")}
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini" activeApp === "gemini"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
+1 -1
View File
@@ -56,7 +56,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
" "
aria-label={t("common.close")} aria-label={t("common.close")}
> >
<X className="w-3 h-3 text-muted-foreground" /> <X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
</button> </button>
</div> </div>
); );
+3 -3
View File
@@ -114,7 +114,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{/* 第一行:更新时间和刷新按钮 */} {/* 第一行:更新时间和刷新按钮 */}
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
{/* 上次查询时间 */} {/* 上次查询时间 */}
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1"> <span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} /> <Clock size={10} />
{lastQueriedAt {lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t) ? formatRelativeTime(lastQueriedAt, now, t)
@@ -128,7 +128,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
refetch(); refetch();
}} }}
disabled={loading} disabled={loading}
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground" className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
title={t("usage.refreshUsage")} title={t("usage.refreshUsage")}
> >
<RefreshCw size={12} className={loading ? "animate-spin" : ""} /> <RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -191,7 +191,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 自动查询时间提示 */} {/* 自动查询时间提示 */}
{lastQueriedAt && ( {lastQueriedAt && (
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1"> <span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} /> <Clock size={10} />
{formatRelativeTime(lastQueriedAt, now, t)} {formatRelativeTime(lastQueriedAt, now, t)}
</span> </span>
+35 -47
View File
@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -33,57 +32,46 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
}; };
}, [isOpen]); }, [isOpen]);
if (!isOpen) return null;
return createPortal( return createPortal(
<AnimatePresence> <div
{isOpen && ( className="fixed inset-0 z-[60] flex flex-col"
<motion.div style={{ backgroundColor: "hsl(var(--background))" }}
initial={{ opacity: 0 }} >
animate={{ opacity: 1 }} {/* Header */}
exit={{ opacity: 0 }} <div
transition={{ duration: 0.2 }} className="flex-shrink-0 py-3 border-b border-border-default"
className="fixed inset-0 z-[60] flex flex-col" style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }} style={{ backgroundColor: "hsl(var(--background))" }}
> >
{/* Header */} <div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
<div {footer}
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button
type="button"
variant="outline"
size="icon"
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div> </div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
)}
</motion.div>
)} )}
</AnimatePresence>, </div>,
document.body, document.body,
); );
}; };
+3 -3
View File
@@ -191,14 +191,14 @@ export function EnvWarningBanner({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<label <label
htmlFor={key} htmlFor={key}
className="block text-sm font-medium text-foreground cursor-pointer" className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
> >
{conflict.varName} {conflict.varName}
</label> </label>
<p className="text-xs text-muted-foreground mt-1 break-all"> <p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
{t("env.field.value")}: {conflict.varValue} {t("env.field.value")}: {conflict.varValue}
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{t("env.field.source")}:{" "} {t("env.field.source")}:{" "}
{getSourceDescription(conflict)} {getSourceDescription(conflict)}
</p> </p>
+15 -15
View File
@@ -239,7 +239,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4"> <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Hint */} {/* Hint */}
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3"> <div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.wizard.hint")} {t("mcp.wizard.hint")}
</p> </p>
</div> </div>
@@ -248,7 +248,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<div className="space-y-4 min-h-[400px]"> <div className="space-y-4 min-h-[400px]">
{/* Type */} {/* Type */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-foreground"> <label className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.type")} <span className="text-red-500">*</span> {t("mcp.wizard.type")} <span className="text-red-500">*</span>
</label> </label>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -262,7 +262,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
} }
className="w-4 h-4 accent-blue-500" className="w-4 h-4 accent-blue-500"
/> />
<span className="text-sm text-foreground"> <span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeStdio")} {t("mcp.wizard.typeStdio")}
</span> </span>
</label> </label>
@@ -276,7 +276,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
} }
className="w-4 h-4 accent-blue-500" className="w-4 h-4 accent-blue-500"
/> />
<span className="text-sm text-foreground"> <span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeHttp")} {t("mcp.wizard.typeHttp")}
</span> </span>
</label> </label>
@@ -290,7 +290,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
} }
className="w-4 h-4 accent-blue-500" className="w-4 h-4 accent-blue-500"
/> />
<span className="text-sm text-foreground"> <span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeSse")} {t("mcp.wizard.typeSse")}
</span> </span>
</label> </label>
@@ -299,7 +299,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{/* Title */} {/* Title */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-foreground"> <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> {t("mcp.form.title")} <span className="text-red-500">*</span>
</label> </label>
<Input <Input
@@ -317,7 +317,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<> <>
{/* Command */} {/* Command */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-foreground"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.command")}{" "} {t("mcp.wizard.command")}{" "}
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
@@ -333,7 +333,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{/* Args */} {/* Args */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-foreground"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.args")} {t("mcp.wizard.args")}
</label> </label>
<textarea <textarea
@@ -341,13 +341,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardArgs(e.target.value)} onChange={(e) => setWizardArgs(e.target.value)}
placeholder={t("mcp.wizard.argsPlaceholder")} placeholder={t("mcp.wizard.argsPlaceholder")}
rows={3} rows={3}
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 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> </div>
{/* Env */} {/* Env */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-foreground"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.env")} {t("mcp.wizard.env")}
</label> </label>
<textarea <textarea
@@ -355,7 +355,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardEnv(e.target.value)} onChange={(e) => setWizardEnv(e.target.value)}
placeholder={t("mcp.wizard.envPlaceholder")} placeholder={t("mcp.wizard.envPlaceholder")}
rows={3} rows={3}
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 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> </div>
</> </>
@@ -366,7 +366,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<> <>
{/* URL */} {/* URL */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-foreground"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.url")}{" "} {t("mcp.wizard.url")}{" "}
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
@@ -382,7 +382,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{/* Headers */} {/* Headers */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-foreground"> <label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.headers")} {t("mcp.wizard.headers")}
</label> </label>
<textarea <textarea
@@ -390,7 +390,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardHeaders(e.target.value)} onChange={(e) => setWizardHeaders(e.target.value)}
placeholder={t("mcp.wizard.headersPlaceholder")} placeholder={t("mcp.wizard.headersPlaceholder")}
rows={3} rows={3}
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 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> </div>
</> </>
@@ -404,7 +404,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
wizardUrl || wizardUrl ||
wizardHeaders) && ( 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-foreground"> <h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.preview")} {t("mcp.wizard.preview")}
</h3> </h3>
<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"> <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">
+13 -11
View File
@@ -129,18 +129,18 @@ const UnifiedMcpPanel = React.forwardRef<
{/* Content - Scrollable */} {/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24"> <div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? ( {isLoading ? (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")} {t("mcp.loading")}
</div> </div>
) : serverEntries.length === 0 ? ( ) : serverEntries.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server size={24} className="text-muted-foreground" /> <Server size={24} className="text-gray-400 dark:text-gray-500" />
</div> </div>
<h3 className="text-lg font-medium text-foreground mb-2"> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.unifiedPanel.noServers")} {t("mcp.unifiedPanel.noServers")}
</h3> </h3>
<p className="text-muted-foreground text-sm"> <p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")} {t("mcp.emptyDescription")}
</p> </p>
</div> </div>
@@ -237,7 +237,9 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
{/* 左侧:服务器信息 */} {/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{name}</h3> <h3 className="font-medium text-gray-900 dark:text-gray-100">
{name}
</h3>
{docsUrl && ( {docsUrl && (
<Button <Button
type="button" type="button"
@@ -251,12 +253,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
)} )}
</div> </div>
{description && ( {description && (
<p className="text-sm text-muted-foreground line-clamp-2"> <p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{description} {description}
</p> </p>
)} )}
{!description && tags && tags.length > 0 && ( {!description && tags && tags.length > 0 && (
<p className="text-xs text-muted-foreground/70 truncate"> <p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")} {tags.join(", ")}
</p> </p>
)} )}
@@ -267,7 +269,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label <label
htmlFor={`${id}-claude`} htmlFor={`${id}-claude`}
className="text-sm text-foreground/80 cursor-pointer" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
> >
{t("mcp.unifiedPanel.apps.claude")} {t("mcp.unifiedPanel.apps.claude")}
</label> </label>
@@ -283,7 +285,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label <label
htmlFor={`${id}-codex`} htmlFor={`${id}-codex`}
className="text-sm text-foreground/80 cursor-pointer" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
> >
{t("mcp.unifiedPanel.apps.codex")} {t("mcp.unifiedPanel.apps.codex")}
</label> </label>
@@ -299,7 +301,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label <label
htmlFor={`${id}-gemini`} htmlFor={`${id}-gemini`}
className="text-sm text-foreground/80 cursor-pointer" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
> >
{t("mcp.unifiedPanel.apps.gemini")} {t("mcp.unifiedPanel.apps.gemini")}
</label> </label>
+4 -2
View File
@@ -36,9 +36,11 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground mb-1">{prompt.name}</h3> <h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{prompt.name}
</h3>
{prompt.description && ( {prompt.description && (
<p className="text-sm text-muted-foreground truncate"> <p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{prompt.description} {prompt.description}
</p> </p>
)} )}
+8 -5
View File
@@ -108,18 +108,21 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
<div className="flex-1 overflow-y-auto pb-16"> <div className="flex-1 overflow-y-auto pb-16">
{loading ? ( {loading ? (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("prompts.loading")} {t("prompts.loading")}
</div> </div>
) : promptEntries.length === 0 ? ( ) : promptEntries.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<FileText size={24} className="text-muted-foreground" /> <FileText
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div> </div>
<h3 className="text-lg font-medium text-foreground mb-2"> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("prompts.empty")} {t("prompts.empty")}
</h3> </h3>
<p className="text-muted-foreground text-sm"> <p className="text-gray-500 dark:text-gray-400 text-sm">
{t("prompts.emptyDescription")} {t("prompts.emptyDescription")}
</p> </p>
</div> </div>
+35 -121
View File
@@ -1,23 +1,17 @@
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Provider, CustomEndpoint, UniversalProvider } from "@/types"; import type { Provider, CustomEndpoint } from "@/types";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
import { universalProvidersApi } from "@/lib/api";
import { import {
ProviderForm, ProviderForm,
type ProviderFormValues, type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm"; } from "@/components/providers/forms/ProviderForm";
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets"; import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
interface AddProviderDialogProps { interface AddProviderDialogProps {
open: boolean; open: boolean;
@@ -33,46 +27,6 @@ export function AddProviderDialog({
onSubmit, onSubmit,
}: AddProviderDialogProps) { }: AddProviderDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
const [universalFormOpen, setUniversalFormOpen] = useState(false);
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
// Handle universal provider save
const handleUniversalProviderSave = useCallback(
async (provider: UniversalProvider) => {
try {
await universalProvidersApi.upsert(provider);
toast.success(
t("universalProvider.addSuccess", {
defaultValue: "统一供应商添加成功",
}),
);
setUniversalFormOpen(false);
setSelectedUniversalPreset(null);
onOpenChange(false);
} catch (error) {
console.error(
"[AddProviderDialog] Failed to save universal provider",
error,
);
toast.error(
t("universalProvider.addFailed", {
defaultValue: "统一供应商添加失败",
}),
);
}
},
[t, onOpenChange],
);
// Close universal form and return to main dialog
const handleUniversalFormClose = useCallback(() => {
setUniversalFormOpen(false);
setSelectedUniversalPreset(null);
}, []);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (values: ProviderFormValues) => { async (values: ProviderFormValues) => {
@@ -202,86 +156,46 @@ export function AddProviderDialog({
[appId, onSubmit, onOpenChange], [appId, onSubmit, onOpenChange],
); );
// 动态 footer:根据当前 Tab 显示不同按钮 const submitLabel =
const footer = appId === "claude"
activeTab === "app-specific" ? ( ? t("provider.addClaudeProvider")
<> : appId === "codex"
<Button ? t("provider.addCodexProvider")
variant="outline" : t("provider.addGeminiProvider");
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground" const footer = (
> <>
{t("common.cancel")} <Button
</Button> variant="outline"
<Button onClick={() => onOpenChange(false)}
type="submit" className="border-border/20 hover:bg-accent hover:text-accent-foreground"
form="provider-form" >
className="bg-primary text-primary-foreground hover:bg-primary/90" {t("common.cancel")}
> </Button>
<Plus className="h-4 w-4 mr-2" /> <Button
{t("common.add")} type="submit"
</Button> form="provider-form"
</> className="bg-primary text-primary-foreground hover:bg-primary/90"
) : ( >
<> <Plus className="h-4 w-4 mr-2" />
<Button {t("common.add")}
variant="outline" </Button>
onClick={() => onOpenChange(false)} </>
className="border-border/20 hover:bg-accent hover:text-accent-foreground" );
>
{t("common.cancel")}
</Button>
<Button
onClick={() => setUniversalFormOpen(true)}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4 mr-2" />
{t("universalProvider.add")}
</Button>
</>
);
return ( return (
<FullScreenPanel <FullScreenPanel
isOpen={open} isOpen={open}
title={t("provider.addNewProvider")} title={submitLabel}
onClose={() => onOpenChange(false)} onClose={() => onOpenChange(false)}
footer={footer} footer={footer}
> >
<Tabs <ProviderForm
value={activeTab} appId={appId}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")} submitLabel={t("common.add")}
> onSubmit={handleSubmit}
<TabsList className="grid w-full grid-cols-2 mb-6"> onCancel={() => onOpenChange(false)}
<TabsTrigger value="app-specific"> showButtons={false}
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
</TabsList>
<TabsContent value="app-specific" className="mt-0">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</TabsContent>
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
</Tabs>
{/* Universal Provider Form Modal */}
<UniversalProviderFormModal
isOpen={universalFormOpen}
onClose={handleUniversalFormClose}
onSave={handleUniversalProviderSave}
initialPreset={selectedUniversalPreset}
/> />
</FullScreenPanel> </FullScreenPanel>
); );
@@ -1,34 +0,0 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface FailoverPriorityBadgeProps {
priority: number; // 1, 2, 3, ...
className?: string;
}
/**
*
*
*/
export function FailoverPriorityBadge({
priority,
className,
}: FailoverPriorityBadgeProps) {
const { t } = useTranslation();
return (
<div
className={cn(
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold",
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
className,
)}
title={t("failover.priority.tooltip", {
priority,
defaultValue: `故障转移优先级 ${priority}`,
})}
>
P{priority}
</div>
);
}
@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { HealthStatus } from "@/lib/api/model-test"; import type { HealthStatus } from "@/lib/api/model-test";
import { useTranslation } from "react-i18next";
interface HealthStatusIndicatorProps { interface HealthStatusIndicatorProps {
status: HealthStatus; status: HealthStatus;
@@ -12,20 +11,17 @@ interface HealthStatusIndicatorProps {
const statusConfig = { const statusConfig = {
operational: { operational: {
color: "bg-emerald-500", color: "bg-emerald-500",
labelKey: "health.operational", label: "正常",
labelFallback: "正常",
textColor: "text-emerald-600 dark:text-emerald-400", textColor: "text-emerald-600 dark:text-emerald-400",
}, },
degraded: { degraded: {
color: "bg-yellow-500", color: "bg-yellow-500",
labelKey: "health.degraded", label: "降级",
labelFallback: "降级",
textColor: "text-yellow-600 dark:text-yellow-400", textColor: "text-yellow-600 dark:text-yellow-400",
}, },
failed: { failed: {
color: "bg-red-500", color: "bg-red-500",
labelKey: "health.failed", label: "失败",
labelFallback: "失败",
textColor: "text-red-600 dark:text-red-400", textColor: "text-red-600 dark:text-red-400",
}, },
}; };
@@ -35,15 +31,13 @@ export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
responseTimeMs, responseTimeMs,
className, className,
}) => { }) => {
const { t } = useTranslation();
const config = statusConfig[status]; const config = statusConfig[status];
const label = t(config.labelKey, { defaultValue: config.labelFallback });
return ( return (
<div className={cn("flex items-center gap-2", className)}> <div className={cn("flex items-center gap-2", className)}>
<div className={cn("w-2 h-2 rounded-full", config.color)} /> <div className={cn("w-2 h-2 rounded-full", config.color)} />
<span className={cn("text-xs font-medium", config.textColor)}> <span className={cn("text-xs font-medium", config.textColor)}>
{label} {config.label}
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`} {responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
</span> </span>
</div> </div>
+23 -78
View File
@@ -5,7 +5,6 @@ import {
Edit, Edit,
Loader2, Loader2,
Play, Play,
Plus,
TestTube2, TestTube2,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -23,10 +22,6 @@ interface ProviderActionsProps {
onTest?: () => void; onTest?: () => void;
onConfigureUsage: () => void; onConfigureUsage: () => void;
onDelete: () => void; onDelete: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
} }
export function ProviderActions({ export function ProviderActions({
@@ -39,88 +34,38 @@ export function ProviderActions({
onTest, onTest,
onConfigureUsage, onConfigureUsage,
onDelete, onDelete,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
}: ProviderActionsProps) { }: ProviderActionsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1"; const iconButtonClass = "h-8 w-8 p-1";
// 故障转移模式下的按钮逻辑
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击
const handleMainButtonClick = () => {
if (isFailoverMode) {
// 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue);
} else {
// 普通模式:切换供应商
onSwitch();
}
};
// 主按钮的状态和样式
const getMainButtonState = () => {
if (isFailoverMode) {
// 故障转移模式
if (isInFailoverQueue) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
icon: <Check className="h-4 w-4" />,
text: t("failover.inQueue", { defaultValue: "已加入" }),
};
}
return {
disabled: false,
variant: "default" as const,
className:
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
icon: <Plus className="h-4 w-4" />,
text: t("failover.addQueue", { defaultValue: "加入" }),
};
}
// 普通模式
if (isCurrent) {
return {
disabled: true,
variant: "secondary" as const,
className:
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
icon: <Check className="h-4 w-4" />,
text: t("provider.inUse"),
};
}
return {
disabled: false,
variant: "default" as const,
className: isProxyTakeover
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
};
};
const buttonState = getMainButtonState();
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Button <Button
size="sm" size="sm"
variant={buttonState.variant} variant={isCurrent ? "secondary" : "default"}
onClick={handleMainButtonClick} onClick={onSwitch}
disabled={buttonState.disabled} disabled={isCurrent}
className={cn("w-[4.5rem] px-2.5", buttonState.className)} className={cn(
"w-[4.5rem] px-2.5",
isCurrent &&
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
// 代理接管模式下启用按钮使用绿色
!isCurrent &&
isProxyTakeover &&
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
)}
> >
{buttonState.icon} {isCurrent ? (
{buttonState.text} <>
<Check className="h-4 w-4" />
{t("provider.inUse")}
</>
) : (
<>
<Play className="h-4 w-4" />
{t("provider.enable")}
</>
)}
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
+15 -50
View File
@@ -12,7 +12,6 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon"; import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter"; import UsageFooter from "@/components/UsageFooter";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { useProviderHealth } from "@/lib/query/failover"; import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries"; import { useUsageQuery } from "@/lib/query/queries";
@@ -37,12 +36,6 @@ interface ProviderCardProps {
isProxyRunning: boolean; isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
dragHandleProps?: DragHandleProps; dragHandleProps?: DragHandleProps;
// 故障转移相关
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...
isInFailoverQueue?: boolean; // 是否在故障转移队列中
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
} }
const extractApiUrl = (provider: Provider, fallbackText: string) => { const extractApiUrl = (provider: Provider, fallbackText: string) => {
@@ -95,12 +88,6 @@ export function ProviderCard({
isProxyRunning, isProxyRunning,
isProxyTakeover = false, isProxyTakeover = false,
dragHandleProps, dragHandleProps,
// 故障转移相关
isAutoFailoverEnabled = false,
failoverPriority,
isInFailoverQueue = false,
onToggleFailover,
activeProviderId,
}: ProviderCardProps) { }: ProviderCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -161,32 +148,21 @@ export function ProviderCard({
onOpenWebsite(displayUrl); onOpenWebsite(displayUrl);
}; };
// 判断是否是"当前使用中"的供应商
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent
const isActiveProvider = isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider;
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
return ( return (
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300", "relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"bg-card text-card-foreground group", "bg-card text-card-foreground group",
// hover 时的边框效果 // 代理接管模式下 hover 使用绿色边框,否则使用蓝色
isAutoFailoverEnabled || isProxyTakeover isProxyTakeover
? "hover:border-emerald-500/50" ? "hover:border-emerald-500/50"
: "hover:border-border-active", : "hover:border-border-active",
// 当前激活的供应商边框样式 // 代理接管模式下当前供应商使用绿色边框
shouldUseGreen && isProxyTakeover && isCurrent
"border-emerald-500/60 shadow-sm shadow-emerald-500/10", ? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10", : isCurrent
!isActiveProvider && "hover:shadow-sm", ? "border-primary/50 shadow-sm"
: "hover:shadow-sm",
dragHandleProps?.isDragging && dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10", "cursor-grabbing border-primary shadow-lg scale-105 z-10",
)} )}
@@ -194,11 +170,11 @@ export function ProviderCard({
<div <div
className={cn( className={cn(
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none", "absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变 // 代理接管模式使用绿色渐变,否则使用蓝色主色调
shouldUseGreen && "from-emerald-500/10", isProxyTakeover && isCurrent
shouldUseBlue && "from-blue-500/10", ? "from-emerald-500/10"
!isActiveProvider && "from-primary/10", : "from-primary/10",
isActiveProvider ? "opacity-100" : "opacity-0", isCurrent ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -233,20 +209,13 @@ export function ProviderCard({
{provider.name} {provider.name}
</h3> </h3>
{/* 健康状态徽章 */} {/* 健康状态徽章和优先级 */}
{isProxyRunning && isInFailoverQueue && health && ( {isProxyRunning && health && (
<ProviderHealthBadge <ProviderHealthBadge
consecutiveFailures={health.consecutive_failures} consecutiveFailures={health.consecutive_failures}
/> />
)} )}
{/* 故障转移优先级徽章 */}
{isAutoFailoverEnabled &&
isInFailoverQueue &&
failoverPriority && (
<FailoverPriorityBadge priority={failoverPriority} />
)}
{provider.category === "third_party" && {provider.category === "third_party" &&
provider.meta?.isPartner && ( provider.meta?.isPartner && (
<span <span
@@ -339,10 +308,6 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined} onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)} onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)} onDelete={() => onDelete(provider)}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
/> />
</div> </div>
</div> </div>
@@ -1,6 +1,5 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ProviderHealthStatus } from "@/types/proxy"; import { ProviderHealthStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
interface ProviderHealthBadgeProps { interface ProviderHealthBadgeProps {
consecutiveFailures: number; consecutiveFailures: number;
@@ -15,14 +14,11 @@ export function ProviderHealthBadge({
consecutiveFailures, consecutiveFailures,
className, className,
}: ProviderHealthBadgeProps) { }: ProviderHealthBadgeProps) {
const { t } = useTranslation();
// 根据失败次数计算状态 // 根据失败次数计算状态
const getStatus = () => { const getStatus = () => {
if (consecutiveFailures === 0) { if (consecutiveFailures === 0) {
return { return {
labelKey: "health.operational", label: "正常",
labelFallback: "正常",
status: ProviderHealthStatus.Healthy, status: ProviderHealthStatus.Healthy,
color: "bg-green-500", color: "bg-green-500",
// 使用更深/柔和的背景色,去除可能的白色内容感 // 使用更深/柔和的背景色,去除可能的白色内容感
@@ -31,8 +27,7 @@ export function ProviderHealthBadge({
}; };
} else if (consecutiveFailures < 5) { } else if (consecutiveFailures < 5) {
return { return {
labelKey: "health.degraded", label: "降级",
labelFallback: "降级",
status: ProviderHealthStatus.Degraded, status: ProviderHealthStatus.Degraded,
color: "bg-yellow-500", color: "bg-yellow-500",
bgColor: "bg-yellow-500/10", bgColor: "bg-yellow-500/10",
@@ -40,8 +35,7 @@ export function ProviderHealthBadge({
}; };
} else { } else {
return { return {
labelKey: "health.circuitOpen", label: "熔断",
labelFallback: "熔断",
status: ProviderHealthStatus.Failed, status: ProviderHealthStatus.Failed,
color: "bg-red-500", color: "bg-red-500",
bgColor: "bg-red-500/10", bgColor: "bg-red-500/10",
@@ -51,9 +45,6 @@ export function ProviderHealthBadge({
}; };
const statusConfig = getStatus(); const statusConfig = getStatus();
const label = t(statusConfig.labelKey, {
defaultValue: statusConfig.labelFallback,
});
return ( return (
<div <div
@@ -63,13 +54,10 @@ export function ProviderHealthBadge({
statusConfig.textColor, statusConfig.textColor,
className, className,
)} )}
title={t("health.consecutiveFailures", { title={`连续失败 ${consecutiveFailures}`}
count: consecutiveFailures,
defaultValue: `连续失败 ${consecutiveFailures}`,
})}
> >
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} /> <div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
<span>{label}</span> <span>{statusConfig.label}</span>
</div> </div>
); );
} }
+11 -218
View File
@@ -5,31 +5,13 @@ import {
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { import type { CSSProperties } from "react";
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Provider } from "@/types"; import type { Provider } from "@/types";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort"; import { useDragSort } from "@/hooks/useDragSort";
import { useStreamCheck } from "@/hooks/useStreamCheck"; import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import {
useAutoFailoverEnabled,
useFailoverQueue,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
} from "@/lib/query/failover";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface ProviderListProps { interface ProviderListProps {
providers: Record<string, Provider>; providers: Record<string, Provider>;
@@ -45,7 +27,6 @@ interface ProviderListProps {
isLoading?: boolean; isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态 isProxyRunning?: boolean; // 代理服务运行状态
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管) isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
} }
export function ProviderList({ export function ProviderList({
@@ -60,11 +41,9 @@ export function ProviderList({
onOpenWebsite, onOpenWebsite,
onCreate, onCreate,
isLoading = false, isLoading = false,
isProxyRunning = false, isProxyRunning = false, // 默认值为 false
isProxyTakeover = false, isProxyTakeover = false, // 默认值为 false
activeProviderId,
}: ProviderListProps) { }: ProviderListProps) {
const { t } = useTranslation();
const { sortedProviders, sensors, handleDragEnd } = useDragSort( const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers, providers,
appId, appId,
@@ -73,103 +52,17 @@ export function ProviderList({
// 流式健康检查 // 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId); const { checkProvider, isChecking } = useStreamCheck(appId);
// 故障转移相关
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
const isFailoverModeActive =
isProxyTakeover === true && isAutoFailoverEnabled === true;
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
if (!isFailoverModeActive || !failoverQueue) return undefined;
const index = failoverQueue.findIndex(
(item) => item.providerId === providerId,
);
return index >= 0 ? index + 1 : undefined;
},
[isFailoverModeActive, failoverQueue],
);
// 判断供应商是否在故障转移队列中
const isInFailoverQueue = useCallback(
(providerId: string): boolean => {
if (!isFailoverModeActive || !failoverQueue) return false;
return failoverQueue.some((item) => item.providerId === providerId);
},
[isFailoverModeActive, failoverQueue],
);
// 切换供应商的故障转移队列状态
const handleToggleFailover = useCallback(
(providerId: string, enabled: boolean) => {
if (enabled) {
addToQueue.mutate({ appType: appId, providerId });
} else {
removeFromQueue.mutate({ appType: appId, providerId });
}
},
[appId, addToQueue, removeFromQueue],
);
const handleTest = (provider: Provider) => { const handleTest = (provider: Provider) => {
checkProvider(provider.id, provider.name); checkProvider(provider.id, provider.name);
}; };
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if ((event.metaKey || event.ctrlKey) && key === "f") {
event.preventDefault();
setIsSearchOpen(true);
return;
}
if (key === "escape") {
setIsSearchOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
if (isSearchOpen) {
const frame = requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}
}, [isSearchOpen]);
const filteredProviders = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase();
if (!keyword) return sortedProviders;
return sortedProviders.filter((provider) => {
const fields = [provider.name, provider.notes, provider.websiteUrl];
return fields.some((field) =>
field?.toString().toLowerCase().includes(keyword),
);
});
}, [searchTerm, sortedProviders]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{[0, 1, 2].map((index) => ( {[0, 1, 2].map((index) => (
<div <div
key={index} key={index}
className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40" className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
/> />
))} ))}
</div> </div>
@@ -180,18 +73,21 @@ export function ProviderList({
return <ProviderEmptyState onCreate={onCreate} />; return <ProviderEmptyState onCreate={onCreate} />;
} }
const renderProviderList = () => ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={filteredProviders.map((provider) => provider.id)} items={sortedProviders.map((provider) => provider.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-3"> <div
{filteredProviders.map((provider) => ( className="space-y-3 animate-slide-up"
style={{ animationDelay: "0.1s" }}
>
{sortedProviders.map((provider) => (
<SortableProviderCard <SortableProviderCard
key={provider.id} key={provider.id}
provider={provider} provider={provider}
@@ -207,98 +103,12 @@ export function ProviderList({
isTesting={isChecking(provider.id)} isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover} isProxyTakeover={isProxyTakeover}
// 故障转移相关:联动状态
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/> />
))} ))}
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
); );
return (
<div className="mt-4 space-y-4">
<AnimatePresence>
{isSearchOpen && (
<motion.div
key="provider-search"
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0"
>
<div className="p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md">
<div className="relative flex items-center gap-2">
<Search className="absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t("provider.searchPlaceholder", {
defaultValue: "Search name, notes, or URL...",
})}
aria-label={t("provider.searchAriaLabel", {
defaultValue: "Search providers",
})}
className="pr-16 pl-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="sm"
className="absolute text-xs -translate-y-1/2 right-11 top-1/2"
onClick={() => setSearchTerm("")}
>
{t("common.clear", { defaultValue: "Clear" })}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto"
onClick={() => setIsSearchOpen(false)}
aria-label={t("provider.searchCloseAriaLabel", {
defaultValue: "Close provider search",
})}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>
{t("provider.searchScopeHint", {
defaultValue: "Matches provider name, notes, and URL.",
})}
</span>
<span>
{t("provider.searchCloseHint", {
defaultValue: "Press Esc to close",
})}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{filteredProviders.length === 0 ? (
<div className="px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground">
{t("provider.noSearchResults", {
defaultValue: "No providers match your search.",
})}
</div>
) : (
renderProviderList()
)}
</div>
);
} }
interface SortableProviderCardProps { interface SortableProviderCardProps {
@@ -315,12 +125,6 @@ interface SortableProviderCardProps {
isTesting: boolean; isTesting: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
isProxyTakeover: boolean; isProxyTakeover: boolean;
// 故障转移相关
isAutoFailoverEnabled: boolean;
failoverPriority?: number;
isInFailoverQueue: boolean;
onToggleFailover: (enabled: boolean) => void;
activeProviderId?: string;
} }
function SortableProviderCard({ function SortableProviderCard({
@@ -337,11 +141,6 @@ function SortableProviderCard({
isTesting, isTesting,
isProxyRunning, isProxyRunning,
isProxyTakeover, isProxyTakeover,
isAutoFailoverEnabled,
failoverPriority,
isInFailoverQueue,
onToggleFailover,
activeProviderId,
}: SortableProviderCardProps) { }: SortableProviderCardProps) {
const { const {
setNodeRef, setNodeRef,
@@ -380,12 +179,6 @@ function SortableProviderCard({
listeners, listeners,
isDragging, isDragging,
}} }}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
failoverPriority={failoverPriority}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
activeProviderId={activeProviderId}
/> />
</div> </div>
); );
@@ -30,13 +30,16 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${ const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled disabled
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed" ? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
: "border-border-default bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20" : "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
}`; }`;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor={id} className="block text-sm font-medium text-foreground"> <label
htmlFor={id}
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{label} {required && "*"} {label} {required && "*"}
</label> </label>
<div className="relative"> <div className="relative">
@@ -55,7 +58,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
<button <button
type="button" type="button"
onClick={toggleShowKey} onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors" className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")} aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
> >
{showKey ? <EyeOff size={16} /> : <Eye size={16} />} {showKey ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -52,15 +52,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
<button <button
type="button" type="button"
className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center" className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center"
title={ title={currentIcon ? "点击更换图标" : "点击选择图标"}
currentIcon
? t("providerIcon.clickToChange", {
defaultValue: "点击更换图标",
})
: t("providerIcon.clickToSelect", {
defaultValue: "点击选择图标",
})
}
> >
<ProviderIcon <ProviderIcon
icon={currentIcon} icon={currentIcon}
@@ -153,10 +145,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
<FormItem> <FormItem>
<FormLabel>{t("provider.websiteUrl")}</FormLabel> <FormLabel>{t("provider.websiteUrl")}</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} placeholder="https://" />
{...field}
placeholder={t("providerForm.websiteUrlPlaceholder")}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form"; import { FormLabel } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import EndpointSpeedTest from "./EndpointSpeedTest"; import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared"; import { ApiKeySection, EndpointField } from "./shared";
@@ -40,14 +39,12 @@ interface ClaudeFormFieldsProps {
// Model Selector // Model Selector
shouldShowModelSelector: boolean; shouldShowModelSelector: boolean;
claudeModel: string; claudeModel: string;
reasoningModel: string;
defaultHaikuModel: string; defaultHaikuModel: string;
defaultSonnetModel: string; defaultSonnetModel: string;
defaultOpusModel: string; defaultOpusModel: string;
onModelChange: ( onModelChange: (
field: field:
| "ANTHROPIC_MODEL" | "ANTHROPIC_MODEL"
| "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL", | "ANTHROPIC_DEFAULT_OPUS_MODEL",
@@ -56,11 +53,6 @@ interface ClaudeFormFieldsProps {
// Speed Test Endpoints // Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[]; speedTestEndpoints: EndpointCandidate[];
// OpenRouter Compat
showOpenRouterCompatToggle: boolean;
openRouterCompatEnabled: boolean;
onOpenRouterCompatChange: (enabled: boolean) => void;
} }
export function ClaudeFormFields({ export function ClaudeFormFields({
@@ -85,15 +77,11 @@ export function ClaudeFormFields({
onCustomEndpointsChange, onCustomEndpointsChange,
shouldShowModelSelector, shouldShowModelSelector,
claudeModel, claudeModel,
reasoningModel,
defaultHaikuModel, defaultHaikuModel,
defaultSonnetModel, defaultSonnetModel,
defaultOpusModel, defaultOpusModel,
onModelChange, onModelChange,
speedTestEndpoints, speedTestEndpoints,
showOpenRouterCompatToggle,
openRouterCompatEnabled,
onOpenRouterCompatChange,
}: ClaudeFormFieldsProps) { }: ClaudeFormFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -174,28 +162,6 @@ export function ClaudeFormFields({
/> />
)} )}
{showOpenRouterCompatToggle && (
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("providerForm.openrouterCompatMode", {
defaultValue: "OpenRouter 兼容模式",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.openrouterCompatModeHint", {
defaultValue:
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
})}
</p>
</div>
<Switch
checked={openRouterCompatEnabled}
onCheckedChange={onOpenRouterCompatChange}
/>
</div>
)}
{/* 模型选择器 */} {/* 模型选择器 */}
{shouldShowModelSelector && ( {shouldShowModelSelector && (
<div className="space-y-3"> <div className="space-y-3">
@@ -219,27 +185,6 @@ export function ClaudeFormFields({
/> />
</div> </div>
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel", {
defaultValue: "推理模型 (Thinking)",
})}
</FormLabel>
<Input
id="reasoningModel"
type="text"
value={reasoningModel}
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
{/* 默认 Haiku */} {/* 默认 Haiku */}
<div className="space-y-2"> <div className="space-y-2">
<FormLabel htmlFor="claudeDefaultHaikuModel"> <FormLabel htmlFor="claudeDefaultHaikuModel">
@@ -47,7 +47,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="codexAuth" htmlFor="codexAuth"
className="block text-sm font-medium text-foreground" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
{t("codexConfig.authJson")} {t("codexConfig.authJson")}
</label> </label>
@@ -67,7 +67,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
)} )}
{!error && ( {!error && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.authJsonHint")} {t("codexConfig.authJsonHint")}
</p> </p>
)} )}
@@ -120,12 +120,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label <label
htmlFor="codexConfig" htmlFor="codexConfig"
className="block text-sm font-medium text-foreground" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
{t("codexConfig.configToml")} {t("codexConfig.configToml")}
</label> </label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer"> <label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={useCommonConfig} checked={useCommonConfig}
@@ -167,7 +167,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
)} )}
{!configError && ( {!configError && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.configTomlHint")} {t("codexConfig.configTomlHint")}
</p> </p>
)} )}
@@ -98,7 +98,7 @@ export function CodexFormFields({
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="codexModelName" htmlFor="codexModelName"
className="block text-sm font-medium text-foreground" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
{t("codexConfig.modelName", { defaultValue: "模型名称" })} {t("codexConfig.modelName", { defaultValue: "模型名称" })}
</label> </label>
@@ -110,9 +110,9 @@ export function CodexFormFields({
placeholder={t("codexConfig.modelNamePlaceholder", { placeholder={t("codexConfig.modelNamePlaceholder", {
defaultValue: "例如: gpt-5-codex", defaultValue: "例如: gpt-5-codex",
})} })}
className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors" className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500 dark:text-gray-400">
{t("codexConfig.modelNameHint", { {t("codexConfig.modelNameHint", {
defaultValue: "指定使用的模型,将自动更新到 config.toml 中", defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
})} })}
@@ -47,7 +47,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="geminiEnv" htmlFor="geminiEnv"
className="block text-sm font-medium text-foreground" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })} {t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label> </label>
@@ -69,7 +69,7 @@ GEMINI_MODEL=gemini-3-pro-preview`}
)} )}
{!error && ( {!error && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500 dark:text-gray-400">
{t("geminiConfig.envFileHint", { {t("geminiConfig.envFileHint", {
defaultValue: "使用 .env 格式配置 Gemini 环境变量", defaultValue: "使用 .env 格式配置 Gemini 环境变量",
})} })}
@@ -124,14 +124,14 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label <label
htmlFor="geminiConfig" htmlFor="geminiConfig"
className="block text-sm font-medium text-foreground" className="block text-sm font-medium text-gray-900 dark:text-gray-100"
> >
{t("geminiConfig.configJson", { {t("geminiConfig.configJson", {
defaultValue: "配置文件 (config.json)", defaultValue: "配置文件 (config.json)",
})} })}
</label> </label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer"> <label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={useCommonConfig} checked={useCommonConfig}
@@ -180,7 +180,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
)} )}
{!configError && ( {!configError && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500 dark:text-gray-400">
{t("geminiConfig.configJsonHint", { {t("geminiConfig.configJsonHint", {
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)", defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
})} })}
@@ -20,7 +20,6 @@ import {
geminiProviderPresets, geminiProviderPresets,
type GeminiProviderPreset, type GeminiProviderPreset,
} from "@/config/geminiProviderPresets"; } from "@/config/geminiProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates"; import { getCodexCustomTemplate } from "@/config/codexTemplates";
@@ -73,8 +72,6 @@ interface ProviderFormProps {
submitLabel: string; submitLabel: string;
onSubmit: (values: ProviderFormValues) => void; onSubmit: (values: ProviderFormValues) => void;
onCancel: () => void; onCancel: () => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
initialData?: { initialData?: {
name?: string; name?: string;
websiteUrl?: string; websiteUrl?: string;
@@ -94,8 +91,6 @@ export function ProviderForm({
submitLabel, submitLabel,
onSubmit, onSubmit,
onCancel, onCancel,
onUniversalPresetSelect,
onManageUniversalProviders,
initialData, initialData,
showButtons = true, showButtons = true,
}: ProviderFormProps) { }: ProviderFormProps) {
@@ -167,8 +162,6 @@ export function ProviderForm({
mode: "onSubmit", mode: "onSubmit",
}); });
const settingsConfigValue = form.watch("settingsConfig");
// 使用 API Key hook // 使用 API Key hook
const { const {
apiKey, apiKey,
@@ -194,10 +187,9 @@ export function ProviderForm({
}, },
}); });
// 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型) // 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型)
const { const {
claudeModel, claudeModel,
reasoningModel,
defaultHaikuModel, defaultHaikuModel,
defaultSonnetModel, defaultSonnetModel,
defaultOpusModel, defaultOpusModel,
@@ -207,53 +199,6 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config), onConfigChange: (config) => form.setValue("settingsConfig", config),
}); });
const isOpenRouterProvider = useMemo(() => {
if (appId !== "claude") return false;
const normalized = baseUrl.trim().toLowerCase();
if (normalized.includes("openrouter.ai")) {
return true;
}
try {
const config = JSON.parse(settingsConfigValue || "{}");
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
return typeof envUrl === "string" && envUrl.includes("openrouter.ai");
} catch {
return false;
}
}, [appId, baseUrl, settingsConfigValue]);
const openRouterCompatEnabled = useMemo(() => {
if (!isOpenRouterProvider) return false;
try {
const config = JSON.parse(settingsConfigValue || "{}");
const raw = config?.openrouter_compat_mode;
if (typeof raw === "boolean") return raw;
if (typeof raw === "number") return raw !== 0;
if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
return normalized === "true" || normalized === "1";
}
} catch {
// ignore
}
return true;
}, [isOpenRouterProvider, settingsConfigValue]);
const handleOpenRouterCompatChange = useCallback(
(enabled: boolean) => {
try {
const currentConfig = JSON.parse(
form.getValues("settingsConfig") || "{}",
);
currentConfig.openrouter_compat_mode = enabled;
form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2));
} catch {
// ignore
}
},
[form],
);
// 使用 Codex 配置 hook (仅 Codex 模式) // 使用 Codex 配置 hook (仅 Codex 模式)
const { const {
codexAuth, codexAuth,
@@ -808,8 +753,6 @@ export function ProviderForm({
categoryKeys={categoryKeys} categoryKeys={categoryKeys}
presetCategoryLabels={presetCategoryLabels} presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange} onPresetChange={handlePresetChange}
onUniversalPresetSelect={onUniversalPresetSelect}
onManageUniversalProviders={onManageUniversalProviders}
category={category} category={category}
/> />
)} )}
@@ -846,15 +789,11 @@ export function ProviderForm({
} }
shouldShowModelSelector={category !== "official"} shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel} claudeModel={claudeModel}
reasoningModel={reasoningModel}
defaultHaikuModel={defaultHaikuModel} defaultHaikuModel={defaultHaikuModel}
defaultSonnetModel={defaultSonnetModel} defaultSonnetModel={defaultSonnetModel}
defaultOpusModel={defaultOpusModel} defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange} onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints} speedTestEndpoints={speedTestEndpoints}
showOpenRouterCompatToggle={isOpenRouterProvider}
openRouterCompatEnabled={openRouterCompatEnabled}
onOpenRouterCompatChange={handleOpenRouterCompatChange}
/> />
)} )}
@@ -1,16 +1,11 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form"; import { FormLabel } from "@/components/ui/form";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons"; import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { Zap, Star, Layers, Settings2 } from "lucide-react"; import { Zap, Star } from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets"; import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets"; import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
import type { ProviderCategory } from "@/types"; import type { ProviderCategory } from "@/types";
import {
universalProviderPresets,
type UniversalProviderPreset,
} from "@/config/universalProviderPresets";
import { ProviderIcon } from "@/components/ProviderIcon";
type PresetEntry = { type PresetEntry = {
id: string; id: string;
@@ -23,8 +18,6 @@ interface ProviderPresetSelectorProps {
categoryKeys: string[]; categoryKeys: string[];
presetCategoryLabels: Record<string, string>; presetCategoryLabels: Record<string, string>;
onPresetChange: (value: string) => void; onPresetChange: (value: string) => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
category?: ProviderCategory; // 当前选中的分类 category?: ProviderCategory; // 当前选中的分类
} }
@@ -34,8 +27,6 @@ export function ProviderPresetSelector({
categoryKeys, categoryKeys,
presetCategoryLabels, presetCategoryLabels,
onPresetChange, onPresetChange,
onUniversalPresetSelect,
onManageUniversalProviders,
category, category,
}: ProviderPresetSelectorProps) { }: ProviderPresetSelectorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -158,7 +149,10 @@ export function ProviderPresetSelector({
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`} className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
style={getPresetButtonStyle(isSelected, entry.preset)} style={getPresetButtonStyle(isSelected, entry.preset)}
title={ title={
presetCategoryLabels[category] ?? t("providerPreset.other") presetCategoryLabels[category] ??
t("providerPreset.categoryOther", {
defaultValue: "其他",
})
} }
> >
{renderPresetIcon(entry.preset)} {renderPresetIcon(entry.preset)}
@@ -173,49 +167,6 @@ export function ProviderPresetSelector({
}); });
})} })}
</div> </div>
{/* 统一供应商预设(新的一行) */}
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => onUniversalPresetSelect(preset)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative"
title={t("universalProvider.hint", {
defaultValue:
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
})}
>
<ProviderIcon icon={preset.icon} name={preset.name} size={14} />
{preset.name}
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
</button>
))}
{/* 管理统一供应商按钮 */}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4" />
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</button>
)}
</div>
</>
)}
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p> <p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
</div> </div>
); );
@@ -7,14 +7,13 @@ interface UseModelStateProps {
/** /**
* *
* ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL * ANTHROPIC_MODEL ANTHROPIC_SMALL_FAST_MODEL
*/ */
export function useModelState({ export function useModelState({
settingsConfig, settingsConfig,
onConfigChange, onConfigChange,
}: UseModelStateProps) { }: UseModelStateProps) {
const [claudeModel, setClaudeModel] = useState(""); const [claudeModel, setClaudeModel] = useState("");
const [reasoningModel, setReasoningModel] = useState("");
const [defaultHaikuModel, setDefaultHaikuModel] = useState(""); const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
const [defaultSonnetModel, setDefaultSonnetModel] = useState(""); const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
const [defaultOpusModel, setDefaultOpusModel] = useState(""); const [defaultOpusModel, setDefaultOpusModel] = useState("");
@@ -30,10 +29,6 @@ export function useModelState({
const env = cfg?.env || {}; const env = cfg?.env || {};
const model = const model =
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
const reasoning =
typeof env.ANTHROPIC_REASONING_MODEL === "string"
? env.ANTHROPIC_REASONING_MODEL
: "";
const small = const small =
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
? env.ANTHROPIC_SMALL_FAST_MODEL ? env.ANTHROPIC_SMALL_FAST_MODEL
@@ -52,7 +47,6 @@ export function useModelState({
: model || small; : model || small;
setClaudeModel(model || ""); setClaudeModel(model || "");
setReasoningModel(reasoning || "");
setDefaultHaikuModel(haiku || ""); setDefaultHaikuModel(haiku || "");
setDefaultSonnetModel(sonnet || ""); setDefaultSonnetModel(sonnet || "");
setDefaultOpusModel(opus || ""); setDefaultOpusModel(opus || "");
@@ -65,14 +59,12 @@ export function useModelState({
( (
field: field:
| "ANTHROPIC_MODEL" | "ANTHROPIC_MODEL"
| "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL", | "ANTHROPIC_DEFAULT_OPUS_MODEL",
value: string, value: string,
) => { ) => {
if (field === "ANTHROPIC_MODEL") setClaudeModel(value); if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
setDefaultHaikuModel(value); setDefaultHaikuModel(value);
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL") if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
@@ -106,8 +98,6 @@ export function useModelState({
return { return {
claudeModel, claudeModel,
setClaudeModel, setClaudeModel,
reasoningModel,
setReasoningModel,
defaultHaikuModel, defaultHaikuModel,
setDefaultHaikuModel, setDefaultHaikuModel,
defaultSonnetModel, defaultSonnetModel,
@@ -40,7 +40,7 @@ export function EndpointField({
<button <button
type="button" type="button"
onClick={onManageClick} onClick={onManageClick}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
> >
<Zap className="h-3.5 w-3.5" /> <Zap className="h-3.5 w-3.5" />
{manageButtonLabel || defaultManageLabel} {manageButtonLabel || defaultManageLabel}
+143 -219
View File
@@ -6,67 +6,51 @@ import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2, Info } from "lucide-react"; import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy"; import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
export interface AutoFailoverConfigPanelProps { export interface AutoFailoverConfigPanelProps {
appType: string; enabled: boolean;
disabled?: boolean; onEnabledChange: (enabled: boolean) => void;
} }
export function AutoFailoverConfigPanel({ export function AutoFailoverConfigPanel({
appType, enabled,
disabled = false, onEnabledChange: _onEnabledChange,
}: AutoFailoverConfigPanelProps) { }: AutoFailoverConfigPanelProps) {
// Note: onEnabledChange is currently unused but kept in the interface
// for potential future use by parent components
void _onEnabledChange;
const { t } = useTranslation(); const { t } = useTranslation();
const { data: config, isLoading, error } = useAppProxyConfig(appType); const { data: config, isLoading, error } = useCircuitBreakerConfig();
const updateConfig = useUpdateAppProxyConfig(); const updateConfig = useUpdateCircuitBreakerConfig();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
autoFailoverEnabled: false, failureThreshold: 5,
maxRetries: 3, successThreshold: 2,
streamingFirstByteTimeout: 30, timeoutSeconds: 60,
streamingIdleTimeout: 60, errorRateThreshold: 0.5,
nonStreamingTimeout: 300, minRequests: 10,
circuitFailureThreshold: 5,
circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60,
circuitErrorRateThreshold: 0.5,
circuitMinRequests: 10,
}); });
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setFormData({ setFormData({
autoFailoverEnabled: config.autoFailoverEnabled, ...config,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
}); });
} }
}, [config]); }, [config]);
const handleSave = async () => { const handleSave = async () => {
if (!config) return;
try { try {
await updateConfig.mutateAsync({ await updateConfig.mutateAsync({
appType, failureThreshold: formData.failureThreshold,
enabled: config.enabled, successThreshold: formData.successThreshold,
autoFailoverEnabled: formData.autoFailoverEnabled, timeoutSeconds: formData.timeoutSeconds,
maxRetries: formData.maxRetries, errorRateThreshold: formData.errorRateThreshold,
streamingFirstByteTimeout: formData.streamingFirstByteTimeout, minRequests: formData.minRequests,
streamingIdleTimeout: formData.streamingIdleTimeout,
nonStreamingTimeout: formData.nonStreamingTimeout,
circuitFailureThreshold: formData.circuitFailureThreshold,
circuitSuccessThreshold: formData.circuitSuccessThreshold,
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
circuitMinRequests: formData.circuitMinRequests,
}); });
toast.success( toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"), t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -82,16 +66,7 @@ export function AutoFailoverConfigPanel({
const handleReset = () => { const handleReset = () => {
if (config) { if (config) {
setFormData({ setFormData({
autoFailoverEnabled: config.autoFailoverEnabled, ...config,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
}); });
} }
}; };
@@ -104,10 +79,16 @@ export function AutoFailoverConfigPanel({
); );
} }
const isDisabled = disabled || updateConfig.isPending;
return ( return (
<div className="border-0 rounded-none shadow-none bg-transparent"> <div className="border-0 rounded-none shadow-none bg-transparent">
{/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
Since we need it in the accordion header, and this component is inside the content, we can use a portal or
absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
Better yet, let's just render the content directly without the wrapping Card header/collapse logic
since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
*/}
<div className="space-y-4"> <div className="space-y-4">
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -133,48 +114,22 @@ export function AutoFailoverConfigPanel({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`maxRetries-${appType}`}> <Label htmlFor="failureThreshold">
{t("proxy.autoFailover.maxRetries", "最大重试次数")}
</Label>
<Input
id={`maxRetries-${appType}`}
type="number"
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) =>
setFormData({
...formData,
maxRetries: parseInt(e.target.value) || 3,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.maxRetriesHint",
"请求失败时的重试次数(0-10",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`failureThreshold-${appType}`}>
{t("proxy.autoFailover.failureThreshold", "失败阈值")} {t("proxy.autoFailover.failureThreshold", "失败阈值")}
</Label> </Label>
<Input <Input
id={`failureThreshold-${appType}`} id="failureThreshold"
type="number" type="number"
min="1" min="1"
max="20" max="20"
value={formData.circuitFailureThreshold} value={formData.failureThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
circuitFailureThreshold: parseInt(e.target.value) || 5, failureThreshold: parseInt(e.target.value) || 5,
}) })
} }
disabled={isDisabled} disabled={!enabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -183,123 +138,59 @@ export function AutoFailoverConfigPanel({
)} )}
</p> </p>
</div> </div>
<div className="space-y-2">
<Label htmlFor="timeoutSeconds">
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id="timeoutSeconds"
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={!enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
</div> </div>
</div> </div>
{/* 超时配置 */} {/* 熔断器高级配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4"> <div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold"> <h4 className="text-sm font-semibold">
{t("proxy.autoFailover.timeoutSettings", "超时配置")} {t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
</h4> </h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`streamingFirstByte-${appType}`}> <Label htmlFor="successThreshold">
{t(
"proxy.autoFailover.streamingFirstByte",
"流式首字节超时(秒)",
)}
</Label>
<Input
id={`streamingFirstByte-${appType}`}
type="number"
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) =>
setFormData({
...formData,
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingFirstByteHint",
"等待首个数据块的最大时间",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`streamingIdle-${appType}`}>
{t("proxy.autoFailover.streamingIdle", "流式静默超时(秒)")}
</Label>
<Input
id={`streamingIdle-${appType}`}
type="number"
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingIdleHint",
"数据块之间的最大间隔",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`nonStreaming-${appType}`}>
{t("proxy.autoFailover.nonStreaming", "非流式超时(秒)")}
</Label>
<Input
id={`nonStreaming-${appType}`}
type="number"
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: parseInt(e.target.value) || 300,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.nonStreamingHint",
"非流式请求的总超时时间",
)}
</p>
</div>
</div>
</div>
{/* 熔断器配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器配置")}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor={`successThreshold-${appType}`}>
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")} {t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
</Label> </Label>
<Input <Input
id={`successThreshold-${appType}`} id="successThreshold"
type="number" type="number"
min="1" min="1"
max="10" max="10"
value={formData.circuitSuccessThreshold} value={formData.successThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
circuitSuccessThreshold: parseInt(e.target.value) || 2, successThreshold: parseInt(e.target.value) || 2,
}) })
} }
disabled={isDisabled} disabled={!enabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -310,50 +201,23 @@ export function AutoFailoverConfigPanel({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`timeoutSeconds-${appType}`}> <Label htmlFor="errorRateThreshold">
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id={`timeoutSeconds-${appType}`}
type="number"
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`errorRateThreshold-${appType}`}>
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")} {t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
</Label> </Label>
<Input <Input
id={`errorRateThreshold-${appType}`} id="errorRateThreshold"
type="number" type="number"
min="0" min="0"
max="100" max="100"
step="5" step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)} value={Math.round(formData.errorRateThreshold * 100)}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
circuitErrorRateThreshold: errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
(parseInt(e.target.value) || 50) / 100,
}) })
} }
disabled={isDisabled} disabled={!enabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -364,22 +228,22 @@ export function AutoFailoverConfigPanel({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`minRequests-${appType}`}> <Label htmlFor="minRequests">
{t("proxy.autoFailover.minRequests", "最小请求数")} {t("proxy.autoFailover.minRequests", "最小请求数")}
</Label> </Label>
<Input <Input
id={`minRequests-${appType}`} id="minRequests"
type="number" type="number"
min="5" min="5"
max="100" max="100"
value={formData.circuitMinRequests} value={formData.minRequests}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
circuitMinRequests: parseInt(e.target.value) || 10, minRequests: parseInt(e.target.value) || 10,
}) })
} }
disabled={isDisabled} disabled={!enabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -393,10 +257,17 @@ export function AutoFailoverConfigPanel({
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={handleReset} disabled={isDisabled}> <Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !enabled}
>
{t("common.reset", "重置")} {t("common.reset", "重置")}
</Button> </Button>
<Button onClick={handleSave} disabled={isDisabled}> <Button
onClick={handleSave}
disabled={updateConfig.isPending || !enabled}
>
{updateConfig.isPending ? ( {updateConfig.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -410,6 +281,59 @@ export function AutoFailoverConfigPanel({
)} )}
</Button> </Button>
</div> </div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("proxy.autoFailover.explanationTitle", "工作原理")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
</strong>
{t(
"proxy.autoFailover.failureThresholdExplain",
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
</strong>
{t(
"proxy.autoFailover.timeoutExplain",
"熔断器打开后,等待此时间后尝试半开状态",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
</strong>
{t(
"proxy.autoFailover.successThresholdExplain",
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
</strong>
{t(
"proxy.autoFailover.errorRateExplain",
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
)}
</li>
</ul>
</div>
</div> </div>
</div> </div>
); );
+176 -62
View File
@@ -2,14 +2,37 @@
* *
* *
* *
* -
* - / * - /
* - sort_index * - /
*/ */
import { useState } from "react"; import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react"; import {
GripVertical,
Plus,
Trash2,
Loader2,
Info,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -28,8 +51,8 @@ import {
useAvailableProvidersForFailover, useAvailableProvidersForFailover,
useAddToFailoverQueue, useAddToFailoverQueue,
useRemoveFromFailoverQueue, useRemoveFromFailoverQueue,
useAutoFailoverEnabled, useReorderFailoverQueue,
useSetAutoFailoverEnabled, useSetFailoverItemEnabled,
} from "@/lib/query/failover"; } from "@/lib/query/failover";
interface FailoverQueueManagerProps { interface FailoverQueueManagerProps {
@@ -44,10 +67,6 @@ export function FailoverQueueManager({
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedProviderId, setSelectedProviderId] = useState<string>(""); const [selectedProviderId, setSelectedProviderId] = useState<string>("");
// 故障转移开关状态(每个应用独立)
const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);
const setFailoverEnabled = useSetAutoFailoverEnabled();
// 查询数据 // 查询数据
const { const {
data: queue, data: queue,
@@ -60,11 +79,59 @@ export function FailoverQueueManager({
// Mutations // Mutations
const addToQueue = useAddToFailoverQueue(); const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue(); const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
const setItemEnabled = useSetFailoverItemEnabled();
// 切换故障转移开关 // 拖拽配置
const handleToggleFailover = (enabled: boolean) => { const sensors = useSensors(
setFailoverEnabled.mutate({ appType, enabled }); useSensor(PointerSensor, {
}; activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 排序后的队列
const sortedQueue = useMemo(() => {
if (!queue) return [];
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
}, [queue]);
// 处理拖拽结束
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !sortedQueue) return;
const oldIndex = sortedQueue.findIndex(
(item) => item.providerId === active.id,
);
const newIndex = sortedQueue.findIndex(
(item) => item.providerId === over.id,
);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
const providerIds = reordered.map((item) => item.providerId);
try {
await reorderQueue.mutateAsync({ appType, providerIds });
toast.success(
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
": " +
String(error),
);
}
},
[sortedQueue, appType, reorderQueue, t],
);
// 添加供应商到队列 // 添加供应商到队列
const handleAddProvider = async () => { const handleAddProvider = async () => {
@@ -104,6 +171,19 @@ export function FailoverQueueManager({
} }
}; };
// 切换启用状态
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
try {
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
} catch (error) {
toast.error(
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
": " +
String(error),
);
}
};
if (isQueueLoading) { if (isQueueLoading) {
return ( return (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
@@ -123,41 +203,13 @@ export function FailoverQueueManager({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 自动故障转移开关 */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{t("proxy.failover.autoSwitch", {
defaultValue: "自动故障转移",
})}
</span>
{isFailoverEnabled && (
<span className="px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
{t("common.enabled", { defaultValue: "已开启" })}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("proxy.failover.autoSwitchDescription", {
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
})}
</p>
</div>
<Switch
checked={isFailoverEnabled}
onCheckedChange={handleToggleFailover}
disabled={disabled || setFailoverEnabled.isPending}
/>
</div>
{/* 说明信息 */} {/* 说明信息 */}
<Alert className="border-blue-500/40 bg-blue-500/10"> <Alert className="border-blue-500/40 bg-blue-500/10">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription className="text-sm"> <AlertDescription className="text-sm">
{t( {t(
"proxy.failoverQueue.info", "proxy.failoverQueue.info",
"队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。", "当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
)} )}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -208,7 +260,7 @@ export function FailoverQueueManager({
</div> </div>
{/* 队列列表 */} {/* 队列列表 */}
{!queue || queue.length === 0 ? ( {sortedQueue.length === 0 ? (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center"> <div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t(
@@ -218,26 +270,39 @@ export function FailoverQueueManager({
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <DndContext
{queue.map((item, index) => ( sensors={sensors}
<QueueItem collisionDetection={closestCenter}
key={item.providerId} onDragEnd={handleDragEnd}
item={item} >
index={index} <SortableContext
disabled={disabled} items={sortedQueue.map((item) => item.providerId)}
onRemove={handleRemoveProvider} strategy={verticalListSortingStrategy}
isRemoving={removeFromQueue.isPending} >
/> <div className="space-y-2">
))} {sortedQueue.map((item, index) => (
</div> <SortableQueueItem
key={item.providerId}
item={item}
index={index}
disabled={disabled}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending}
isToggling={setItemEnabled.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
)} )}
{/* 队列说明 */} {/* 队列说明 */}
{queue && queue.length > 0 && ( {sortedQueue.length > 0 && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
"proxy.failoverQueue.orderHint", "proxy.failoverQueue.dragHint",
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。", "拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
)} )}
</p> </p>
)} )}
@@ -245,29 +310,65 @@ export function FailoverQueueManager({
); );
} }
interface QueueItemProps { interface SortableQueueItemProps {
item: FailoverQueueItem; item: FailoverQueueItem;
index: number; index: number;
disabled: boolean; disabled: boolean;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string) => void; onRemove: (providerId: string) => void;
isRemoving: boolean; isRemoving: boolean;
isToggling: boolean;
} }
function QueueItem({ function SortableQueueItem({
item, item,
index, index,
disabled, disabled,
onToggleEnabled,
onRemove, onRemove,
isRemoving, isRemoving,
}: QueueItemProps) { isToggling,
}: SortableQueueItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: item.providerId, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return ( return (
<div <div
ref={setNodeRef}
style={style}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors", "flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
isDragging && "opacity-50 shadow-lg",
!item.enabled && "opacity-60",
)} )}
> >
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
{...attributes}
{...listeners}
disabled={disabled}
aria-label={t("provider.dragHandle", "拖拽排序")}
>
<GripVertical className="h-5 w-5" />
</button>
{/* 序号 */} {/* 序号 */}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium"> <div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{index + 1} {index + 1}
@@ -275,11 +376,24 @@ function QueueItem({
{/* 供应商名称 */} {/* 供应商名称 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block"> <span
className={cn(
"text-sm font-medium truncate block",
!item.enabled && "text-muted-foreground line-through",
)}
>
{item.providerName} {item.providerName}
</span> </span>
</div> </div>
{/* 启用开关 */}
<Switch
checked={item.enabled}
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
disabled={disabled || isToggling}
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
/>
{/* 删除按钮 */} {/* 删除按钮 */}
<Button <Button
variant="ghost" variant="ghost"
+68 -306
View File
@@ -1,54 +1,24 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { import {
Activity, Activity,
Clock, Clock,
TrendingUp, TrendingUp,
Server, Server,
ListOrdered, ListOrdered,
Save, Settings,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { useFailoverQueue } from "@/lib/query/failover"; import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover"; import { useProviderHealth } from "@/lib/query/failover";
import {
useProxyTakeoverStatus,
useSetProxyTakeoverForApp,
useGlobalProxyConfig,
useUpdateGlobalProxyConfig,
} from "@/lib/query/proxy";
import type { ProxyStatus } from "@/types/proxy"; import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
export function ProxyPanel() { export function ProxyPanel() {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus(); const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
// 获取应用接管状态
const { data: takeoverStatus } = useProxyTakeoverStatus();
const setTakeoverForApp = useSetProxyTakeoverForApp();
// 获取全局代理配置
const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态
const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(5000);
// 同步全局配置到本地状态
useEffect(() => {
if (globalConfig) {
setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort);
}
}, [globalConfig]);
// 获取所有三个应用类型的故障转移队列(不包含当前供应商) // 获取所有三个应用类型的故障转移队列(不包含当前供应商)
// 当前供应商始终优先,队列仅用于失败后的备用顺序 // 当前供应商始终优先,队列仅用于失败后的备用顺序
@@ -56,69 +26,6 @@ export function ProxyPanel() {
const { data: codexQueue = [] } = useFailoverQueue("codex"); const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini"); const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
try {
await setTakeoverForApp.mutateAsync({ appType, enabled });
toast.success(
enabled
? t("proxy.takeover.enabled", {
app: appType,
defaultValue: `${appType} 接管已启用`,
})
: t("proxy.takeover.disabled", {
app: appType,
defaultValue: `${appType} 接管已关闭`,
}),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.takeover.failed", {
defaultValue: "切换接管状态失败",
}),
);
}
};
const handleLoggingChange = async (enabled: boolean) => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
enableLogging: enabled,
});
toast.success(
enabled
? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" })
: t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }),
);
}
};
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress,
listenPort,
});
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }),
);
}
};
const formatUptime = (seconds: number): string => { const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
@@ -140,11 +47,18 @@ export function ProxyPanel() {
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4"> <div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div> <div>
<p className="text-xs text-muted-foreground mb-2"> <div className="flex items-center justify-between mb-2">
{t("proxy.panel.serviceAddress", { <p className="text-xs text-muted-foreground"></p>
defaultValue: "服务地址", <Button
})} size="sm"
</p> variant="ghost"
onClick={() => setShowSettings(true)}
className="h-7 gap-1.5 text-xs"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60"> <code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port} http://{status.address}:{status.port}
@@ -156,28 +70,16 @@ export function ProxyPanel() {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`http://${status.address}:${status.port}`, `http://${status.address}:${status.port}`,
); );
toast.success( toast.success("地址已复制", { closeButton: true });
t("proxy.panel.addressCopied", {
defaultValue: "地址已复制",
}),
{ closeButton: true },
);
}} }}
> >
{t("common.copy")}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground mt-2">
{t("proxy.settings.restartRequired", {
defaultValue: "修改监听地址/端口需要先停止代理服务",
})}
</p>
</div> </div>
<div className="pt-3 border-t border-border space-y-2"> <div className="pt-3 border-t border-border space-y-2">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">使</p>
{t("provider.inUse")}
</p>
{status.active_targets && status.active_targets.length > 0 ? ( {status.active_targets && status.active_targets.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{status.active_targets.map((target) => ( {status.active_targets.map((target) => (
@@ -199,79 +101,18 @@ export function ProxyPanel() {
</div> </div>
) : status.current_provider ? ( ) : status.current_provider ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("proxy.panel.currentProvider", { Provider{" "}
defaultValue: "当前 Provider",
})}{" "}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{status.current_provider} {status.current_provider}
</span> </span>
</p> </p>
) : ( ) : (
<p className="text-sm text-yellow-600 dark:text-yellow-400"> <p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("proxy.panel.waitingFirstRequest", { Provider
defaultValue: "当前 Provider:等待首次请求…",
})}
</p> </p>
)} )}
</div> </div>
{/* 应用接管开关 */}
<div className="pt-3 border-t border-border space-y-3">
<p className="text-xs text-muted-foreground">
{t("proxyConfig.appTakeover", {
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
</div>
{/* 日志记录开关 */}
<div className="pt-3 border-t border-border">
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</Label>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</p>
</div>
<Switch
checked={globalConfig?.enableLogging ?? true}
onCheckedChange={handleLoggingChange}
disabled={updateGlobalConfig.isPending}
/>
</div>
</div>
{/* 供应商队列 - 按应用类型分组展示 */} {/* 供应商队列 - 按应用类型分组展示 */}
{(claudeQueue.length > 0 || {(claudeQueue.length > 0 ||
codexQueue.length > 0 || codexQueue.length > 0 ||
@@ -280,7 +121,7 @@ export function ProxyPanel() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" /> <ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.title")}
</p> </p>
</div> </div>
@@ -289,10 +130,13 @@ export function ProxyPanel() {
<ProviderQueueGroup <ProviderQueueGroup
appType="claude" appType="claude"
appLabel="Claude" appLabel="Claude"
targets={claudeQueue.map((item) => ({ targets={claudeQueue
id: item.providerId, .filter((item) => item.enabled)
name: item.providerName, .sort((a, b) => a.queueOrder - b.queueOrder)
}))} .map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status} status={status}
/> />
)} )}
@@ -302,10 +146,13 @@ export function ProxyPanel() {
<ProviderQueueGroup <ProviderQueueGroup
appType="codex" appType="codex"
appLabel="Codex" appLabel="Codex"
targets={codexQueue.map((item) => ({ targets={codexQueue
id: item.providerId, .filter((item) => item.enabled)
name: item.providerName, .sort((a, b) => a.queueOrder - b.queueOrder)
}))} .map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status} status={status}
/> />
)} )}
@@ -315,10 +162,13 @@ export function ProxyPanel() {
<ProviderQueueGroup <ProviderQueueGroup
appType="gemini" appType="gemini"
appLabel="Gemini" appLabel="Gemini"
targets={geminiQueue.map((item) => ({ targets={geminiQueue
id: item.providerId, .filter((item) => item.enabled)
name: item.providerName, .sort((a, b) => a.queueOrder - b.queueOrder)
}))} .map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status} status={status}
/> />
)} )}
@@ -329,139 +179,52 @@ export function ProxyPanel() {
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<StatCard <StatCard
icon={<Activity className="h-4 w-4" />} icon={<Activity className="h-4 w-4" />}
label={t("proxy.panel.stats.activeConnections", { label="活跃连接"
defaultValue: "活跃连接",
})}
value={status.active_connections} value={status.active_connections}
/> />
<StatCard <StatCard
icon={<TrendingUp className="h-4 w-4" />} icon={<TrendingUp className="h-4 w-4" />}
label={t("proxy.panel.stats.totalRequests", { label="总请求数"
defaultValue: "总请求数",
})}
value={status.total_requests} value={status.total_requests}
/> />
<StatCard <StatCard
icon={<Clock className="h-4 w-4" />} icon={<Clock className="h-4 w-4" />}
label={t("proxy.panel.stats.successRate", { label="成功率"
defaultValue: "成功率",
})}
value={`${status.success_rate.toFixed(1)}%`} value={`${status.success_rate.toFixed(1)}%`}
variant={status.success_rate > 90 ? "success" : "warning"} variant={status.success_rate > 90 ? "success" : "warning"}
/> />
<StatCard <StatCard
icon={<Clock className="h-4 w-4" />} icon={<Clock className="h-4 w-4" />}
label={t("proxy.panel.stats.uptime", { label="运行时间"
defaultValue: "运行时间",
})}
value={formatUptime(status.uptime_seconds)} value={formatUptime(status.uptime_seconds)}
/> />
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="text-center py-10 text-muted-foreground">
{/* 空白区域避免冲突 */} <div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<div className="h-4"></div> <Server className="h-8 w-8" />
{/* 基础设置 - 监听地址/端口 */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="listen-address">
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</Label>
<Input
id="listen-address"
value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)}
placeholder="127.0.0.1"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="listen-port">
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</Label>
<Input
id="listen-port"
type="number"
value={listenPort}
onChange={(e) =>
setListenPort(parseInt(e.target.value) || 5000)
}
placeholder="5000"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", {
defaultValue: "代理服务器监听的端口号(1024 ~ 65535",
})}
</p>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSaveBasicConfig}
disabled={updateGlobalConfig.isPending}
>
{updateGlobalConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", { defaultValue: "保存中..." })}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", { defaultValue: "保存" })}
</>
)}
</Button>
</div>
</div>
{/* 代理服务已停止提示 */}
<div className="text-center py-6 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p>
<p className="text-sm text-muted-foreground">
{t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p>
</div> </div>
<p className="text-base font-medium text-foreground mb-1">
</p>
<p className="text-sm text-muted-foreground mb-4">
使
</p>
<Button
size="sm"
variant="outline"
onClick={() => setShowSettings(true)}
className="gap-1.5"
>
<Settings className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</section> </section>
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
</> </>
); );
} }
@@ -556,7 +319,6 @@ function ProviderQueueItem({
appType, appType,
isCurrent, isCurrent,
}: ProviderQueueItemProps) { }: ProviderQueueItemProps) {
const { t } = useTranslation();
const { data: health } = useProviderHealth(provider.id, appType); const { data: health } = useProviderHealth(provider.id, appType);
return ( return (
@@ -582,7 +344,7 @@ function ProviderQueueItem({
</span> </span>
{isCurrent && ( {isCurrent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary"> <span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
{t("provider.inUse")} 使
</span> </span>
)} )}
</div> </div>
@@ -0,0 +1,397 @@
/**
*
*/
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useProxyConfig } from "@/hooks/useProxyConfig";
import { useEffect, useMemo } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理)
type ProxyConfigForm = Omit<ProxyConfig, "enabled">;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
.number()
.min(
0,
t("proxy.settings.validation.timeoutNonNegative", {
defaultValue: "超时时间不能为负数",
}),
)
.max(
600,
t("proxy.settings.validation.timeoutMax", {
defaultValue: "超时时间最多600秒",
}),
)
.refine((value) => value === 0 || value >= 10, {
message: t("proxy.settings.validation.timeoutRange", {
defaultValue: "请输入 0 或 10-600 之间的数值",
}),
});
return z.object({
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
t("proxy.settings.validation.addressInvalid", {
defaultValue: "请输入有效的IP地址",
}),
),
listen_port: z
.number()
.min(
1024,
t("proxy.settings.validation.portMin", {
defaultValue: "端口必须大于1024",
}),
)
.max(
65535,
t("proxy.settings.validation.portMax", {
defaultValue: "端口必须小于65535",
}),
),
max_retries: z
.number()
.min(
0,
t("proxy.settings.validation.retryMin", {
defaultValue: "重试次数不能为负",
}),
)
.max(
10,
t("proxy.settings.validation.retryMax", {
defaultValue: "重试次数不能超过10",
}),
),
request_timeout: requestTimeoutSchema,
enable_logging: z.boolean(),
});
};
interface ProxySettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProxySettingsDialog({
open,
onOpenChange,
}: ProxySettingsDialogProps) {
const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
const { t } = useTranslation();
const schema = useMemo(() => createProxyConfigSchema(t), [t]);
const closePanel = () => onOpenChange(false);
const form = useForm<ProxyConfigForm>({
resolver: zodResolver(schema),
defaultValues: {
listen_address: "127.0.0.1",
listen_port: 5000,
max_retries: 3,
request_timeout: 300,
enable_logging: true,
},
});
// 当配置加载完成后更新表单
useEffect(() => {
if (config) {
form.reset({
...config,
});
}
}, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => {
try {
// 添加 enabled 字段(从当前配置中获取,保持不变)
const configToSave: ProxyConfig = {
...data,
enabled: config?.enabled ?? true,
};
await updateConfig(configToSave);
closePanel();
} catch (error) {
console.error("Save config failed:", error);
}
};
const formId = "proxy-settings-form";
return (
<FullScreenPanel
isOpen={open}
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
onClose={closePanel}
footer={
<>
<Button
type="button"
variant="outline"
onClick={closePanel}
disabled={isUpdating}
>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="submit"
form={formId}
disabled={isUpdating || isLoading}
>
{isUpdating
? t("common.saving", { defaultValue: "保存中..." })
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
</Button>
</>
}
>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("proxy.settings.description", {
defaultValue:
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
})}
</p>
<Alert className="border-emerald-500/40 bg-emerald-500/10">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{t("proxy.settings.alert.autoApply", {
defaultValue:
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
})}
</AlertDescription>
</Alert>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="listen_address"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{ defaultValue: "127.0.0.1" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listen_port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{ defaultValue: "5000" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenPort.description", {
defaultValue:
"代理服务器监听的端口号(1024 ~ 65535",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.advanced.title", {
defaultValue: "高级参数",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.advanced.description", {
defaultValue: "控制请求的稳定性和日志记录。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.maxRetries.label", {
defaultValue: "最大重试次数",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.maxRetries.placeholder",
{ defaultValue: "3" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.maxRetries.description", {
defaultValue: "请求失败时的重试次数(0 ~ 10)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="request_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.requestTimeout.label", {
defaultValue: "请求超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.requestTimeout.placeholder",
{ defaultValue: "0(不限)或 300" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.requestTimeout.description", {
defaultValue:
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="enable_logging"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</section>
</form>
</Form>
</div>
</FullScreenPanel>
);
}
+30 -26
View File
@@ -49,39 +49,43 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
return ( return (
<div <div
className={cn("p-1 rounded-xl transition-all", className)} className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
takeoverEnabled
? "bg-emerald-500/10 border border-emerald-500/30"
: "bg-muted/50 hover:bg-muted",
className,
)}
title={tooltipText} title={tooltipText}
> >
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default"> {isPending ? (
{isPending ? ( <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> ) : (
) : ( <Radio
<Radio
className={cn(
"h-4 w-4 transition-colors",
takeoverEnabled
? "text-emerald-500 animate-pulse"
: "text-muted-foreground",
)}
/>
)}
<span
className={cn( className={cn(
"text-sm font-medium transition-colors select-none", "h-4 w-4 transition-colors",
takeoverEnabled takeoverEnabled
? "text-emerald-600 dark:text-emerald-400" ? "text-emerald-500 animate-pulse"
: "text-muted-foreground", : "text-muted-foreground",
)} )}
>
Proxy
</span>
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
/> />
</div> )}
<span
className={cn(
"text-sm font-medium transition-colors select-none",
takeoverEnabled
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
Proxy
</span>
<Switch
checked={takeoverEnabled}
onCheckedChange={handleToggle}
disabled={isPending}
className="ml-1"
/>
</div> </div>
); );
} }
+1
View File
@@ -3,3 +3,4 @@
*/ */
export { ProxyPanel } from "./ProxyPanel"; export { ProxyPanel } from "./ProxyPanel";
export { ProxySettingsDialog } from "./ProxySettingsDialog";
@@ -93,7 +93,7 @@ export function ImportExportSection({
type="button" type="button"
onClick={onClear} onClick={onClear}
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10" className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
aria-label={t("common.clear")} aria-label="Clear selection"
> >
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
</button> </button>
+80 -128
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { import {
Loader2, Loader2,
Save, Save,
@@ -182,6 +181,7 @@ export function SettingsPage({
stopWithRestore, stopWithRestore,
isPending: isProxyPending, isPending: isProxyPending,
} = useProxyStatus(); } = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true);
const handleToggleProxy = async (checked: boolean) => { const handleToggleProxy = async (checked: boolean) => {
try { try {
@@ -214,19 +214,16 @@ export function SettingsPage({
<TabsTrigger value="advanced"> <TabsTrigger value="advanced">
{t("settings.tabAdvanced")} {t("settings.tabAdvanced")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="usage">{t("usage.title")}</TabsTrigger> <TabsTrigger value="usage">
{t("usage.title", "使用统计")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger> <TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList> </TabsList>
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<TabsContent value="general" className="space-y-6 mt-0"> <TabsContent value="general" className="space-y-6 mt-0">
{settings ? ( {settings ? (
<motion.div <>
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<LanguageSettings <LanguageSettings
value={settings.language} value={settings.language}
onChange={(lang) => handleAutoSave({ language: lang })} onChange={(lang) => handleAutoSave({ language: lang })}
@@ -236,18 +233,13 @@ export function SettingsPage({
settings={settings} settings={settings}
onChange={handleAutoSave} onChange={handleAutoSave}
/> />
</motion.div> </>
) : null} ) : null}
</TabsContent> </TabsContent>
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6"> <TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
{settings ? ( {settings ? (
<motion.div <div className="space-y-4">
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<Accordion <Accordion
type="multiple" type="multiple"
defaultValue={[]} defaultValue={[]}
@@ -262,10 +254,10 @@ export function SettingsPage({
<FolderSearch className="h-5 w-5 text-primary" /> <FolderSearch className="h-5 w-5 text-primary" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.configDir.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.configDir.description")} ClaudeCodex Gemini
</p> </p>
</div> </div>
</div> </div>
@@ -297,10 +289,10 @@ export function SettingsPage({
<Server className="h-5 w-5 text-green-500" /> <Server className="h-5 w-5 text-green-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
</p> </p>
</div> </div>
</div> </div>
@@ -315,9 +307,7 @@ export function SettingsPage({
<Activity <Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`} className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/> />
{isRunning {isRunning ? "运行中" : "已停止"}
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge> </Badge>
<Switch <Switch
checked={isRunning} checked={isRunning}
@@ -340,10 +330,10 @@ export function SettingsPage({
<Activity className="h-5 w-5 text-indigo-500" /> <Activity className="h-5 w-5 text-indigo-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.modelTest.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.modelTest.description")} 使
</p> </p>
</div> </div>
</div> </div>
@@ -355,118 +345,80 @@ export function SettingsPage({
<AccordionItem <AccordionItem
value="failover" value="failover"
className="rounded-xl glass-card overflow-hidden" className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
> >
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50"> <AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
<div className="flex items-center gap-3"> <AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<Activity className="h-5 w-5 text-orange-500" /> <div className="flex items-center gap-3">
<div className="text-left"> <Activity className="h-5 w-5 text-orange-500" />
<h3 className="text-base font-semibold"> <div className="text-left">
{t("settings.advanced.failover.title")} <h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal"> </h3>
{t("settings.advanced.failover.description")} <p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="space-y-6">
{/* 代理未运行时的提示 */}
{!isRunning && (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("proxy.failover.proxyRequired", {
defaultValue:
"需要先启动代理服务才能配置故障转移",
})}
</p> </p>
</div> </div>
)} </div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
{/* 故障转移设置 - 按应用分组 */} <div className="flex items-center gap-2 pl-4">
<Tabs defaultValue="claude" className="w-full"> <Switch
<TabsList className="grid w-full grid-cols-3"> checked={failoverEnabled}
<TabsTrigger value="claude">Claude</TabsTrigger> onCheckedChange={setFailoverEnabled}
<TabsTrigger value="codex">Codex</TabsTrigger> />
<TabsTrigger value="gemini">Gemini</TabsTrigger> </div>
</TabsList> </AccordionPrimitive.Header>
<TabsContent <AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
value="claude" <div className="space-y-6">
className="mt-4 space-y-6" {/* 故障转移队列管理 */}
> <div className="space-y-4">
<div className="space-y-4"> <div>
<div> <h4 className="text-sm font-semibold">
<h4 className="text-sm font-semibold"> {t("proxy.failoverQueue.title", "故障转移队列")}
{t("proxy.failoverQueue.title")} </h4>
</h4> <p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground"> {t(
{t("proxy.failoverQueue.description")} "proxy.failoverQueue.description",
</p> "管理各应用的供应商故障转移顺序",
</div> )}
</p>
</div>
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
<TabsContent value="claude" className="mt-4">
<FailoverQueueManager <FailoverQueueManager
appType="claude" appType="claude"
disabled={!isRunning} disabled={!failoverEnabled}
/> />
</div> </TabsContent>
<div className="border-t border-border/50 pt-6"> <TabsContent value="codex" className="mt-4">
<AutoFailoverConfigPanel
appType="claude"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="codex"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager <FailoverQueueManager
appType="codex" appType="codex"
disabled={!isRunning} disabled={!failoverEnabled}
/> />
</div> </TabsContent>
<div className="border-t border-border/50 pt-6"> <TabsContent value="gemini" className="mt-4">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="gemini"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager <FailoverQueueManager
appType="gemini" appType="gemini"
disabled={!isRunning} disabled={!failoverEnabled}
/> />
</div> </TabsContent>
<div className="border-t border-border/50 pt-6"> </Tabs>
<AutoFailoverConfigPanel </div>
appType="gemini"
disabled={!isRunning} {/* 熔断器配置 */}
/> <div className="border-t border-border/50 pt-6">
</div> <AutoFailoverConfigPanel
</TabsContent> enabled={failoverEnabled}
</Tabs> onEnabledChange={setFailoverEnabled}
/>
</div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -480,10 +432,10 @@ export function SettingsPage({
<Coins className="h-5 w-5 text-yellow-500" /> <Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.pricing.description")} Token
</p> </p>
</div> </div>
</div> </div>
@@ -502,10 +454,10 @@ export function SettingsPage({
<Database className="h-5 w-5 text-blue-500" /> <Database className="h-5 w-5 text-blue-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.data.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.data.description")}
</p> </p>
</div> </div>
</div> </div>
@@ -545,7 +497,7 @@ export function SettingsPage({
)} )}
</Button> </Button>
</div> </div>
</motion.div> </div>
) : null} ) : null}
</TabsContent> </TabsContent>
@@ -46,14 +46,6 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onChange({ enableClaudePluginIntegration: value }) onChange({ enableClaudePluginIntegration: value })
} }
/> />
<ToggleRow
icon={<MonitorUp className="h-4 w-4 text-cyan-500" />}
title={t("settings.skipClaudeOnboarding")}
description={t("settings.skipClaudeOnboardingDescription")}
checked={!!settings.skipClaudeOnboarding}
onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}
/>
</div> </div>
</section> </section>
); );

Some files were not shown because too many files have changed in this diff Show More