Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2af8c82977 | |||
| d4286ab90a | |||
| f7c6947220 | |||
| 8fccc2a286 | |||
| 7fdaeacb5b | |||
| 9716eb797f | |||
| 91deaf094e | |||
| 3a548152a9 | |||
| a8f7cda167 | |||
| 8fe5c1041a | |||
| a24753f074 | |||
| 079ee687a8 | |||
| e08c67b88f | |||
| bb2756d0fb | |||
| c87bb43aaa | |||
| 8f58c08d0d | |||
| e6f18ba801 | |||
| 7d495aa772 | |||
| db8180aa31 | |||
| 1586451862 | |||
| bf570b6d2a | |||
| 26c3f05daf | |||
| d303706d51 | |||
| 97495d1550 | |||
| a1537807eb | |||
| f047960a33 | |||
| ace9b38cee | |||
| b67cdbb18c | |||
| c4f1e90893 | |||
| ddbff070d5 | |||
| ca7cb398c2 | |||
| 2fb3b5405a | |||
| ec649e7718 | |||
| 3da5525c79 | |||
| 44ca688253 | |||
| 5fe5ed98be | |||
| b2a9e91d70 | |||
| 4a1a997935 | |||
| c4535c894a | |||
| 64e0cabaa7 | |||
| 8ecb41d25e | |||
| 3e8f84481d | |||
| ba59483b33 | |||
| b6ff721d67 | |||
| 1706c9a26f | |||
| 5bce6d6020 | |||
| 6bdbb4df23 |
@@ -1 +1 @@
|
|||||||
v22.4.1
|
22.12.0
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -15,7 +15,7 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
|||||||
|
|
||||||
## ❤️Sponsor
|
## ❤️Sponsor
|
||||||
|
|
||||||

|
[](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)!
|
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,19 +23,18 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
<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>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"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
<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>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>
|
<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>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
<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>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>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>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
@@ -48,7 +47,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
### Current Version: v3.8.3 | [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)**
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## ❤️スポンサー
|
## ❤️スポンサー
|
||||||
|
|
||||||

|
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||||
|
|
||||||
本プロジェクトは 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,20 +23,18 @@
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
<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>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"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
<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>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</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>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
<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>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
|
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
@@ -49,7 +47,7 @@
|
|||||||
|
|
||||||
## 特長
|
## 特長
|
||||||
|
|
||||||
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## ❤️赞助商
|
## ❤️赞助商
|
||||||
|
|
||||||

|
[](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)购买可以享受九折优惠。
|
感谢智谱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"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
<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>感谢 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"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
|
<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>感谢闪电说赞助了本项目!闪电说是本地优先的 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.2 | [完整更新日志](CHANGELOG.md)
|
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
**v3.8.0 重大更新(2025-11-28)**
|
**v3.8.0 重大更新(2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -4,7 +4,7 @@
|
|||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"config": "tailwind.config.cjs",
|
||||||
"css": "src/index.css",
|
"css": "src/index.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.0-1",
|
"version": "3.9.0-2",
|
||||||
"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",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@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",
|
||||||
@@ -34,7 +36,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": "^5.0.0",
|
"vite": "^7.3.0",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -85,4 +87,4 @@
|
|||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
@@ -586,6 +586,12 @@ 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"
|
||||||
@@ -695,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-beta.1"
|
version = "3.9.0-2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -2214,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2330,6 +2336,19 @@ 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"
|
||||||
@@ -2759,6 +2778,16 @@ 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"
|
||||||
@@ -2774,7 +2803,7 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation 0.3.1",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -3563,6 +3592,19 @@ 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"
|
||||||
@@ -3694,6 +3736,15 @@ 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"
|
||||||
@@ -4987,6 +5038,7 @@ dependencies = [
|
|||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"http-range",
|
"http-range",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -5055,7 +5107,7 @@ dependencies = [
|
|||||||
"ico",
|
"ico",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"plist",
|
"plist",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -5810,7 +5862,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation 0.3.1",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-1"
|
version = "3.9.0-2"
|
||||||
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"] }
|
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -1,16 +1,36 @@
|
|||||||
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 app_path =
|
let exe_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 消除平台差异
|
||||||
// Windows/Linux: new() 接受 3 参数
|
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
|
||||||
// macOS: new() 接受 4 参数(含 hidden 参数)
|
// Windows/Linux: 使用注册表/XDG autostart
|
||||||
// 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())
|
||||||
@@ -47,3 +67,50 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,55 @@ 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()));
|
||||||
|
|||||||
@@ -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,29 +56,47 @@ 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 reorder_failover_queue(
|
pub async fn get_auto_failover_enabled(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
provider_ids: Vec<String>,
|
) -> Result<bool, String> {
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.reorder_failover_queue(&app_type, &provider_ids)
|
.get_proxy_config_for_app(&app_type)
|
||||||
|
.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_failover_item_enabled(
|
pub async fn set_auto_failover_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
|
||||||
.set_failover_item_enabled(&app_type, &provider_id, enabled)
|
.update_proxy_config_for_app(config)
|
||||||
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ 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> {
|
||||||
@@ -142,11 +148,16 @@ 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;
|
||||||
|
|
||||||
let output = if cfg!(target_os = "windows") {
|
#[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"))
|
||||||
@@ -155,11 +166,17 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
|
|
||||||
match output {
|
match output {
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
(Some(extract_version(&raw)), None)
|
if raw.is_empty() {
|
||||||
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
|
} else {
|
||||||
|
(Some(extract_version(raw)), None)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
Some(if err.is_empty() {
|
Some(if err.is_empty() {
|
||||||
@@ -233,15 +250,31 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
let new_path = format!("{}:{}", path.display(), current_path);
|
let new_path = format!("{}:{}", path.display(), current_path);
|
||||||
|
|
||||||
let output = Command::new(&tool_path)
|
#[cfg(target_os = "windows")]
|
||||||
.arg("--version")
|
let output = {
|
||||||
.env("PATH", &new_path)
|
Command::new(&tool_path)
|
||||||
.output();
|
.arg("--version")
|
||||||
|
.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 stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
return (Some(extract_version(&raw)), None);
|
if !raw.is_empty() {
|
||||||
|
return (Some(extract_version(raw)), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,3 +34,15 @@ 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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -229,3 +229,97 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,15 +11,7 @@ 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(true).await
|
state.proxy_service.start().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 配置)
|
||||||
@@ -70,6 +62,63 @@ 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> {
|
||||||
@@ -111,8 +160,13 @@ 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,
|
||||||
@@ -129,6 +183,68 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,7 @@ 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 {
|
||||||
if item.enabled {
|
ids.insert(item.provider_id);
|
||||||
ids.insert(item.provider_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(ids)
|
Some(ids)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ 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> {
|
||||||
@@ -36,7 +38,8 @@ 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 = Self::sanitize_import_sql(&sql_raw);
|
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||||
|
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||||
|
|
||||||
// 导入前备份现有数据库
|
// 导入前备份现有数据库
|
||||||
let backup_path = self.backup_database_file()?;
|
let backup_path = self.backup_database_file()?;
|
||||||
@@ -51,7 +54,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}")))?;
|
||||||
|
|
||||||
// 补齐缺失表/索引并进行基础校验
|
// 补齐缺失表/索引并进行基础校验
|
||||||
@@ -93,26 +96,17 @@ impl Database {
|
|||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
|
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
|
||||||
fn sanitize_import_sql(sql: &str) -> String {
|
let trimmed = sql.trim_start();
|
||||||
let mut cleaned = String::new();
|
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
|
||||||
let lower_keyword = "sqlite_sequence";
|
return Ok(());
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleaned
|
Err(AppError::localized(
|
||||||
|
"backup.sql.invalid_format",
|
||||||
|
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
|
||||||
|
"Only SQL backups exported by CC Switch are supported.",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||||
@@ -129,8 +123,15 @@ 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 backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||||
let backup_path = backup_dir.join(format!("{backup_id}.db"));
|
let mut backup_id = base_id.clone();
|
||||||
|
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);
|
||||||
|
|||||||
@@ -1,36 +1,32 @@
|
|||||||
//! 故障转移队列 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 queue_order: i32,
|
pub sort_index: Option<usize>,
|
||||||
pub enabled: bool,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 获取故障转移队列(按 queue_order 排序)
|
/// 获取故障转移队列(按 sort_index 排序)
|
||||||
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 fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
|
"SELECT id, name, sort_index
|
||||||
FROM failover_queue fq
|
FROM providers
|
||||||
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
|
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||||
WHERE fq.app_type = ?1
|
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||||
ORDER BY fq.queue_order ASC",
|
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
@@ -39,9 +35,7 @@ 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)?,
|
||||||
queue_order: row.get(2)?,
|
sort_index: 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()))?
|
||||||
@@ -53,43 +47,23 @@ 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 mut result = Vec::new();
|
let result: Vec<Provider> = all_providers
|
||||||
for item in queue {
|
.into_values()
|
||||||
if item.enabled {
|
.filter(|p| p.in_failover_queue)
|
||||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
.collect();
|
||||||
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(
|
||||||
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
|
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
rusqlite::params![provider_id, app_type],
|
||||||
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()))?;
|
||||||
|
|
||||||
@@ -104,90 +78,22 @@ impl Database {
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
// 获取被删除项的 queue_order
|
// 1. 从队列中移除
|
||||||
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(
|
||||||
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
|
||||||
[app_type, provider_id],
|
rusqlite::params![provider_id, app_type],
|
||||||
)
|
)
|
||||||
.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(
|
||||||
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
|
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
rusqlite::params![app_type, provider_id, enabled],
|
rusqlite::params![provider_id, app_type],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +101,11 @@ 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("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
|
conn.execute(
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -209,15 +118,15 @@ impl Database {
|
|||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let count: i32 = conn
|
let in_queue: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
[app_type, provider_id],
|
rusqlite::params![provider_id, app_type],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.unwrap_or(false);
|
||||||
|
|
||||||
Ok(count > 0)
|
Ok(in_queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||||
@@ -226,14 +135,10 @@ 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| !queue_ids.contains(&p.id))
|
.filter(|p| !p.in_failover_queue)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(available)
|
Ok(available)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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 供外部使用
|
||||||
|
|||||||
@@ -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
|
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
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,6 +35,7 @@ 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);
|
||||||
@@ -54,6 +55,7 @@ impl Database {
|
|||||||
meta: Some(meta),
|
meta: Some(meta),
|
||||||
icon,
|
icon,
|
||||||
icon_color,
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
@@ -129,7 +131,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
|
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
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| {
|
||||||
@@ -143,6 +145,7 @@ 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();
|
||||||
@@ -159,6 +162,7 @@ impl Database {
|
|||||||
meta: Some(meta),
|
meta: Some(meta),
|
||||||
icon,
|
icon,
|
||||||
icon_color,
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -184,17 +188,18 @@ 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)
|
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||||
let existing: Option<bool> = tx
|
let existing: Option<(bool, bool)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
params![provider.id, app_type],
|
params![provider.id, app_type],
|
||||||
|row| row.get(0),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
let is_update = existing.is_some();
|
let is_update = existing.is_some();
|
||||||
let is_current = existing.unwrap_or(false);
|
let (is_current, in_failover_queue) =
|
||||||
|
existing.unwrap_or((false, provider.in_failover_queue));
|
||||||
|
|
||||||
if is_update {
|
if is_update {
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||||
@@ -210,8 +215,9 @@ impl Database {
|
|||||||
icon = ?8,
|
icon = ?8,
|
||||||
icon_color = ?9,
|
icon_color = ?9,
|
||||||
meta = ?10,
|
meta = ?10,
|
||||||
is_current = ?11
|
is_current = ?11,
|
||||||
WHERE id = ?12 AND app_type = ?13",
|
in_failover_queue = ?12
|
||||||
|
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(),
|
||||||
@@ -224,6 +230,7 @@ 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,
|
||||||
],
|
],
|
||||||
@@ -234,8 +241,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
|
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||||
params![
|
params![
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
@@ -250,6 +257,7 @@ 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()))?;
|
||||||
|
|||||||
@@ -8,63 +8,66 @@ use crate::proxy::types::*;
|
|||||||
use super::super::{lock_conn, Database};
|
use super::super::{lock_conn, Database};
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
// ==================== Proxy Config ====================
|
// ==================== Global Proxy Config ====================
|
||||||
|
|
||||||
/// 获取代理配置
|
/// 获取全局代理配置(统一字段)
|
||||||
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
///
|
||||||
// 在一个作用域内获取锁并查询,确保锁在await之前释放
|
/// 从 claude 行读取(三行镜像一致)
|
||||||
|
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
let result = {
|
let result = {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT enabled, listen_address, listen_port, max_retries,
|
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
|
||||||
request_timeout, enable_logging, live_takeover_active
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
FROM proxy_config WHERE id = 1",
|
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(ProxyConfig {
|
Ok(GlobalProxyConfig {
|
||||||
enabled: row.get::<_, i32>(0)? != 0,
|
proxy_enabled: row.get::<_, i32>(0)? != 0,
|
||||||
listen_address: row.get(1)?,
|
listen_address: row.get(1)?,
|
||||||
listen_port: row.get::<_, i32>(2)? as u16,
|
listen_port: row.get::<_, i32>(2)? as u16,
|
||||||
max_retries: row.get::<_, i32>(3)? as u8,
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
request_timeout: row.get::<_, i32>(4)? as u64,
|
|
||||||
enable_logging: row.get::<_, i32>(5)? != 0,
|
|
||||||
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}; // conn锁在这里释放
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(config) => Ok(config),
|
Ok(config) => Ok(config),
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
// 如果不存在,插入默认配置
|
// 如果不存在,创建默认配置
|
||||||
let default_config = ProxyConfig::default();
|
self.init_proxy_config_rows().await?;
|
||||||
self.update_proxy_config(default_config.clone()).await?;
|
Ok(GlobalProxyConfig {
|
||||||
Ok(default_config)
|
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())),
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新代理配置
|
/// 更新全局代理配置(镜像写三行)
|
||||||
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
pub async fn update_global_proxy_config(
|
||||||
|
&self,
|
||||||
|
config: GlobalProxyConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO proxy_config
|
"UPDATE proxy_config SET
|
||||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
|
proxy_enabled = ?1,
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
listen_address = ?2,
|
||||||
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
listen_port = ?3,
|
||||||
datetime('now'))",
|
enable_logging = ?4,
|
||||||
|
updated_at = datetime('now')",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
if config.enabled { 1 } else { 0 },
|
if config.proxy_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.request_timeout as i32,
|
|
||||||
if config.enable_logging { 1 } else { 0 },
|
if config.enable_logging { 1 } else { 0 },
|
||||||
if config.live_takeover_active { 1 } else { 0 },
|
|
||||||
"claude", // 兼容旧字段,写入默认值
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -72,21 +75,214 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 Live 接管状态
|
/// 获取应用级代理配置
|
||||||
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
|
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);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
|
"UPDATE proxy_config SET
|
||||||
rusqlite::params![if active { 1 } else { 0 }],
|
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()))?;
|
.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> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
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 app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(ProxyConfig {
|
||||||
|
listen_address: row.get(0)?,
|
||||||
|
listen_port: row.get::<_, i32>(1)? as u16,
|
||||||
|
max_retries: row.get::<_, i32>(2)? as u8,
|
||||||
|
request_timeout: 300, // 废弃字段,返回默认值
|
||||||
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
|
live_takeover_active: false, // 废弃字段
|
||||||
|
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
|
||||||
|
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 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,初始化默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(ProxyConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
|
||||||
|
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 更新所有三行的公共字段
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
listen_address = ?1,
|
||||||
|
listen_port = ?2,
|
||||||
|
max_retries = ?3,
|
||||||
|
enable_logging = ?4,
|
||||||
|
streaming_first_byte_timeout = ?5,
|
||||||
|
streaming_idle_timeout = ?6,
|
||||||
|
non_streaming_timeout = ?7,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
config.listen_address,
|
||||||
|
config.listen_port as i32,
|
||||||
|
config.max_retries as i32,
|
||||||
|
if config.enable_logging { 1 } else { 0 },
|
||||||
|
config.streaming_first_byte_timeout as i32,
|
||||||
|
config.streaming_idle_timeout as i32,
|
||||||
|
config.non_streaming_timeout as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
|
||||||
|
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
|
||||||
|
// 不再使用此字段,由 enabled 字段替代
|
||||||
|
// 保留空实现以兼容旧代码
|
||||||
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> {
|
||||||
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
|
let conn = lock_conn!(self.conn);
|
||||||
self.has_any_live_backup().await
|
let count: i64 = conn
|
||||||
|
.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 ====================
|
||||||
@@ -97,28 +293,45 @@ impl Database {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
) -> Result<ProviderHealth, AppError> {
|
) -> Result<ProviderHealth, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let result = {
|
||||||
|
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健康状态
|
||||||
@@ -223,6 +436,20 @@ 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);
|
||||||
@@ -234,19 +461,22 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Circuit Breaker Config ====================
|
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
|
||||||
|
|
||||||
/// 获取熔断器配置
|
/// 获取熔断器配置(兼容旧接口,从 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> {
|
||||||
let conn = lock_conn!(self.conn);
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
let config = conn
|
let conn = lock_conn!(self.conn);
|
||||||
.query_row(
|
conn.query_row(
|
||||||
"SELECT failure_threshold, success_threshold, timeout_seconds,
|
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
error_rate_threshold, min_requests
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
FROM circuit_breaker_config WHERE id = 1",
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||||
@@ -258,27 +488,39 @@ impl Database {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
Ok(config)
|
match result {
|
||||||
|
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 circuit_breaker_config
|
"UPDATE proxy_config SET
|
||||||
SET failure_threshold = ?1,
|
circuit_failure_threshold = ?1,
|
||||||
success_threshold = ?2,
|
circuit_success_threshold = ?2,
|
||||||
timeout_seconds = ?3,
|
circuit_timeout_seconds = ?3,
|
||||||
error_rate_threshold = ?4,
|
circuit_error_rate_threshold = ?4,
|
||||||
min_requests = ?5,
|
circuit_min_requests = ?5,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = datetime('now')",
|
||||||
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,
|
||||||
|
|||||||
@@ -62,4 +62,70 @@ 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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! 统一供应商 (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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ 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)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -54,47 +55,28 @@ 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,
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||||
server_config TEXT NOT NULL,
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||||
description TEXT,
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||||
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(
|
conn.execute("CREATE TABLE IF NOT EXISTS prompts (
|
||||||
"CREATE TABLE IF NOT EXISTS prompts (
|
id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
|
||||||
id TEXT NOT NULL,
|
description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
|
||||||
app_type TEXT NOT NULL,
|
PRIMARY KEY (id, app_type)
|
||||||
name TEXT NOT NULL,
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
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,
|
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
app_type TEXT NOT NULL,
|
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
|
||||||
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()))?;
|
||||||
@@ -102,169 +84,130 @@ 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,
|
owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
|
||||||
name TEXT NOT NULL,
|
enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
|
||||||
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 (
|
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
|
||||||
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 表 (代理服务器配置)
|
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||||||
// 代理配置表(单例)
|
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 TABLE IF NOT EXISTS proxy_config (
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
|
||||||
listen_port INTEGER NOT NULL DEFAULT 5000,
|
|
||||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
||||||
request_timeout INTEGER NOT NULL DEFAULT 300,
|
|
||||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
|
||||||
target_app TEXT NOT NULL DEFAULT 'claude',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 尝试添加 target_app 列(如果表已存在但缺少该列)
|
|
||||||
// 忽略 "duplicate column name" 错误
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 9. Provider Health 表 (Provider健康状态)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS provider_health (
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
|
||||||
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()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 10. Proxy Request Logs 表 (详细请求日志)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
|
||||||
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()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
conn.execute(
|
// 11. Model Pricing 表
|
||||||
"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,
|
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||||
input_cost_per_million TEXT NOT NULL,
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||||
output_cost_per_million TEXT NOT NULL,
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||||
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(
|
conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
|
||||||
"CREATE TABLE IF NOT EXISTS stream_check_logs (
|
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
|
||||||
provider_id TEXT NOT NULL,
|
response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
|
||||||
provider_name TEXT NOT NULL,
|
retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
|
||||||
app_type TEXT NOT NULL,
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
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
|
||||||
@@ -273,35 +216,13 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 13. Circuit Breaker Config 表 (熔断器配置)
|
// 注意:circuit_breaker_config 已合并到 proxy_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,
|
app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
|
||||||
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()))?;
|
||||||
@@ -312,29 +233,64 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 14. Failover Queue 表 (故障转移队列)
|
// 尝试添加基础配置列到 proxy_config 表(兼容 v3.9.0-2 升级)
|
||||||
conn.execute(
|
let _ = conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS failover_queue (
|
"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
|
||||||
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
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
);
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
let _ = conn.execute(
|
||||||
|
"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 表
|
||||||
conn.execute(
|
let _ = conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
|
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||||
ON failover_queue(app_type, queue_order)",
|
|
||||||
[],
|
[],
|
||||||
)
|
);
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
let _ = conn.execute(
|
||||||
|
"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(())
|
||||||
}
|
}
|
||||||
@@ -472,37 +428,88 @@ 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(
|
||||||
// proxy_request_logs 表(包含所有字段)
|
conn,
|
||||||
conn.execute(
|
"providers",
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
"in_failover_queue",
|
||||||
request_id TEXT PRIMARY KEY,
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
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
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// 添加代理超时配置字段
|
||||||
|
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(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||||
|
ON providers(app_type, in_failover_queue, sort_index)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.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")?;
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
@@ -523,13 +530,11 @@ 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,
|
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||||
input_cost_per_million TEXT NOT NULL,
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||||
output_cost_per_million TEXT NOT NULL,
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
)",
|
||||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -541,6 +546,144 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,53 @@ 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");
|
||||||
|
|
||||||
|
// 模拟测试版 v2:user_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
|
||||||
@@ -245,6 +292,7 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ 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)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ 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};
|
||||||
@@ -133,6 +135,19 @@ 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();
|
||||||
@@ -208,44 +223,6 @@ 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(
|
||||||
@@ -503,11 +480,26 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.show_menu_on_left_click(true);
|
.show_menu_on_left_click(true);
|
||||||
|
|
||||||
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
|
||||||
if let Some(icon) = app.default_window_icon() {
|
#[cfg(target_os = "macos")]
|
||||||
tray_builder = tray_builder.icon(icon.clone());
|
{
|
||||||
} else {
|
if let Some(icon) = macos_tray_icon() {
|
||||||
log::warn!("Failed to get default window icon for tray");
|
tray_builder = tray_builder.icon(icon).icon_as_template(true);
|
||||||
|
} 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)?;
|
||||||
@@ -524,67 +516,33 @@ 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>();
|
||||||
|
|
||||||
// 1. 检测异常退出并恢复 Live 配置
|
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
|
||||||
let is_proxy_running = state.proxy_service.is_running().await;
|
let has_backups = match state.db.has_any_live_backup().await {
|
||||||
if !is_proxy_running {
|
Ok(v) => v,
|
||||||
let takeover_flag = match state.db.is_live_takeover_active().await {
|
Err(e) => {
|
||||||
Ok(active) => active,
|
log::error!("检查 Live 备份失败: {e}");
|
||||||
Err(e) => {
|
false
|
||||||
log::error!("检查接管状态失败: {e}");
|
}
|
||||||
false
|
};
|
||||||
}
|
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
|
||||||
};
|
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
|
||||||
|
|
||||||
let has_backups = match state.db.has_any_live_backup().await {
|
if has_backups || live_taken_over {
|
||||||
Ok(v) => v,
|
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
|
||||||
Err(e) => {
|
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
||||||
log::error!("检查 Live 备份失败: {e}");
|
log::error!("恢复 Live 配置失败: {e}");
|
||||||
false
|
} else {
|
||||||
}
|
log::info!("Live 配置已恢复");
|
||||||
};
|
|
||||||
|
|
||||||
// 兜底检测:旧版本/极端窗口期可能出现“标志未写入,但 Live 已被写成占位符”的残留状态。
|
|
||||||
// 只有在存在备份时才检查占位符,避免误判覆盖用户正常配置。
|
|
||||||
let live_taken_over =
|
|
||||||
has_backups && state.proxy_service.detect_takeover_in_live_configs();
|
|
||||||
|
|
||||||
if takeover_flag || live_taken_over {
|
|
||||||
log::warn!("检测到上次异常退出或残留接管状态,正在恢复 Live 配置...");
|
|
||||||
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
|
||||||
log::error!("恢复 Live 配置失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!("Live 配置已从异常退出中恢复");
|
|
||||||
}
|
|
||||||
} else if has_backups {
|
|
||||||
// 备份残留但 Live 未处于接管状态:清理敏感备份,避免长期存储 Token
|
|
||||||
if let Err(e) = state.db.delete_all_live_backups().await {
|
|
||||||
log::warn!("清理残留 Live 备份失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 自动启动代理服务器(如果配置为启用)
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
match state.db.get_proxy_config().await {
|
restore_proxy_state_on_startup(&state).await;
|
||||||
Ok(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}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -622,6 +580,8 @@ 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,
|
||||||
@@ -636,7 +596,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,
|
||||||
// v3.7.0: Unified MCP management
|
// 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,
|
||||||
@@ -690,13 +650,17 @@ 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,
|
||||||
@@ -711,8 +675,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::reorder_failover_queue,
|
commands::get_auto_failover_enabled,
|
||||||
commands::set_failover_item_enabled,
|
commands::set_auto_failover_enabled,
|
||||||
// Usage statistics
|
// Usage statistics
|
||||||
commands::get_usage_summary,
|
commands::get_usage_summary,
|
||||||
commands::get_usage_trends,
|
commands::get_usage_trends,
|
||||||
@@ -730,6 +694,12 @@ 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
|
||||||
@@ -844,32 +814,91 @@ 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 接管残留仍在(占位符/备份)。
|
||||||
if proxy_service.is_running().await {
|
let has_backups = match state.db.has_any_live_backup().await {
|
||||||
log::info!("检测到代理服务器正在运行,开始清理...");
|
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;
|
||||||
|
|
||||||
// 检查是否处于 Live 接管模式
|
if needs_restore {
|
||||||
if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
|
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
|
||||||
if is_takeover {
|
// 使用 keep_state 版本,保留 settings 表中的代理状态
|
||||||
// 接管模式:停止并恢复配置
|
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
|
||||||
if let Err(e) = proxy_service.stop_with_restore().await {
|
log::error!("退出时恢复 Live 配置失败: {e}");
|
||||||
log::error!("退出时恢复 Live 配置失败: {e}");
|
} else {
|
||||||
} else {
|
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
|
||||||
log::info!("已恢复 Live 配置");
|
}
|
||||||
}
|
return;
|
||||||
} else {
|
}
|
||||||
// 非接管模式:仅停止代理
|
|
||||||
if let Err(e) = proxy_service.stop().await {
|
// 非接管模式:代理在运行则仅停止代理
|
||||||
log::error!("退出时停止代理失败: {e}");
|
if proxy_service.is_running().await {
|
||||||
}
|
log::info!("检测到代理服务器正在运行,开始停止...");
|
||||||
|
if let Err(e) = proxy_service.stop().await {
|
||||||
|
log::error!("退出时停止代理失败: {e}");
|
||||||
|
}
|
||||||
|
log::info!("代理服务器清理完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 启动时恢复代理状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
|
||||||
|
///
|
||||||
|
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||||
|
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||||
|
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||||
|
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||||
|
let mut apps_to_restore = Vec::new();
|
||||||
|
for app_type in ["claude", "codex", "gemini"] {
|
||||||
|
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
|
||||||
|
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!("代理服务器清理完成");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,9 +359,14 @@ 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
|
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
|
||||||
.parse::<toml_edit::DocumentMut>()
|
match content.parse::<toml_edit::DocumentMut>() {
|
||||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
Ok(doc) => doc,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
|
||||||
|
toml_edit::DocumentMut::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toml_edit::DocumentMut::new()
|
toml_edit::DocumentMut::new()
|
||||||
};
|
};
|
||||||
@@ -409,9 +414,14 @@ 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
|
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
|
||||||
.parse::<toml_edit::DocumentMut>()
|
let mut doc = match content.parse::<toml_edit::DocumentMut>() {
|
||||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
Ok(doc) => doc,
|
||||||
|
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()) {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ 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 {
|
||||||
@@ -58,6 +62,7 @@ impl Provider {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,3 +173,286 @@ 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ 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(持有熔断器状态)
|
||||||
@@ -29,24 +39,35 @@ 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>,
|
||||||
timeout_secs: u64,
|
non_streaming_timeout: 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 timeout_secs > 0 {
|
if non_streaming_timeout > 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
|
||||||
@@ -133,13 +154,16 @@ impl RequestForwarder {
|
|||||||
body: Value,
|
body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
providers: Vec<Provider>,
|
||||||
) -> Result<Response, ProxyError> {
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
// 获取适配器
|
// 获取适配器
|
||||||
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(ProxyError::NoAvailableProvider);
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -149,16 +173,27 @@ 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 会占用探测名额)
|
||||||
let permit = self
|
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
|
||||||
.router
|
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
|
||||||
.allow_provider_request(&provider.id, app_type_str)
|
(true, false)
|
||||||
.await;
|
} else {
|
||||||
if !permit.allowed {
|
let permit = self
|
||||||
|
.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,
|
||||||
@@ -167,8 +202,6 @@ impl RequestForwarder {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let used_half_open_permit = permit.used_half_open_permit;
|
|
||||||
|
|
||||||
attempted_providers += 1;
|
attempted_providers += 1;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -268,7 +301,10 @@ impl RequestForwarder {
|
|||||||
latency
|
latency
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(response);
|
return Ok(ForwardResult {
|
||||||
|
response,
|
||||||
|
provider: provider.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let latency = start.elapsed().as_millis() as u64;
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
@@ -309,6 +345,7 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
|
|
||||||
last_error = Some(e);
|
last_error = Some(e);
|
||||||
|
last_provider = Some(provider.clone());
|
||||||
// 继续尝试下一个供应商
|
// 继续尝试下一个供应商
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -330,7 +367,10 @@ impl RequestForwarder {
|
|||||||
provider.name,
|
provider.name,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
return Err(e);
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,7 +388,10 @@ 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(ProxyError::NoAvailableProvider);
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有供应商都失败了
|
// 所有供应商都失败了
|
||||||
@@ -368,7 +411,10 @@ impl RequestForwarder {
|
|||||||
providers.len()
|
providers.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
Err(ForwardError {
|
||||||
|
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||||
|
provider: last_provider,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转发单个请求(使用适配器)
|
/// 转发单个请求(使用适配器)
|
||||||
@@ -384,12 +430,19 @@ 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{}",
|
||||||
@@ -397,10 +450,23 @@ 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(body.clone(), provider)?;
|
let transformed = adapter.transform_request(mapped_body, provider)?;
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] >>> 转换后的请求 JSON:\n{}",
|
"[{}] >>> 转换后的请求 JSON:\n{}",
|
||||||
adapter.name(),
|
adapter.name(),
|
||||||
@@ -408,7 +474,7 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
transformed
|
transformed
|
||||||
} else {
|
} else {
|
||||||
body.clone()
|
mapped_body
|
||||||
};
|
};
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
|
|||||||
@@ -31,13 +31,26 @@ pub struct UsageParserConfig {
|
|||||||
// 模型提取器实现
|
// 模型提取器实现
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Claude 流式响应模型提取(直接使用请求模型)
|
/// Claude 流式响应模型提取(优先使用 usage.model)
|
||||||
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 流式响应模型提取
|
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
||||||
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())
|
||||||
@@ -45,8 +58,15 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codex Responses API 流式响应模型提取
|
/// Codex Responses API 流式响应模型提取(优先使用 usage.model)
|
||||||
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| {
|
||||||
|
|||||||
@@ -5,23 +5,32 @@
|
|||||||
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::ProxyConfig, ProxyError,
|
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, 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 config: ProxyConfig,
|
pub app_config: AppProxyConfig,
|
||||||
/// 选中的 Provider(故障转移链的第一个)
|
/// 选中的 Provider(故障转移链的第一个)
|
||||||
pub provider: Provider,
|
pub provider: Provider,
|
||||||
/// 完整的 Provider 列表(用于故障转移)
|
/// 完整的 Provider 列表(用于故障转移)
|
||||||
@@ -62,7 +71,14 @@ 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();
|
||||||
|
|
||||||
@@ -96,7 +112,7 @@ impl RequestContext {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
start_time,
|
start_time,
|
||||||
config,
|
app_config,
|
||||||
provider,
|
provider,
|
||||||
providers,
|
providers,
|
||||||
current_provider_id,
|
current_provider_id,
|
||||||
@@ -135,13 +151,15 @@ 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.config.request_timeout,
|
self.app_config.non_streaming_timeout as u64,
|
||||||
self.config.max_retries,
|
self.app_config.max_retries as u8,
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,4 +175,13 @@ 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! 重构后的结构:
|
//! 重构后的结构:
|
||||||
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
|
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
|
||||||
//! - 各 handler 只保留独特的业务逻辑
|
//! - 各 handler 只保留独特的业务逻辑
|
||||||
//! - Claude 的格式转换逻辑保留在此文件(独有功能)
|
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
|
||||||
|
|
||||||
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,34 +54,23 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
|||||||
/// 处理 /v1/messages 请求(Claude API)
|
/// 处理 /v1/messages 请求(Claude API)
|
||||||
///
|
///
|
||||||
/// Claude 处理器包含独特的格式转换逻辑:
|
/// Claude 处理器包含独特的格式转换逻辑:
|
||||||
/// - 当使用 OpenRouter 等中转服务时,需要将 Anthropic 格式转换为 OpenAI 格式
|
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic ↔ OpenAI 转换)
|
||||||
/// - 响应需要从 OpenAI 格式转回 Anthropic 格式
|
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||||
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 ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
let mut 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 response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Claude,
|
&AppType::Claude,
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
@@ -91,13 +80,30 @@ pub async fn handle_messages(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
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}");
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ pub async fn handle_messages(
|
|||||||
|
|
||||||
/// Claude 格式转换处理(独有逻辑)
|
/// Claude 格式转换处理(独有逻辑)
|
||||||
///
|
///
|
||||||
/// 处理 OpenRouter 等需要格式转换的中转服务
|
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||||||
async fn handle_claude_transform(
|
async fn handle_claude_transform(
|
||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
@@ -164,10 +170,14 @@ 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();
|
||||||
@@ -295,7 +305,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 ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
@@ -309,7 +319,7 @@ pub async fn handle_chat_completions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
@@ -319,13 +329,19 @@ pub async fn handle_chat_completions(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -337,7 +353,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 ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
@@ -345,7 +361,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 response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/responses",
|
"/v1/responses",
|
||||||
@@ -355,13 +371,19 @@ pub async fn handle_responses(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -379,7 +401,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 ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||||
.await?
|
.await?
|
||||||
.with_model_from_uri(&uri);
|
.with_model_from_uri(&uri);
|
||||||
|
|
||||||
@@ -397,7 +419,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 response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Gemini,
|
&AppType::Gemini,
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -407,13 +429,19 @@ pub async fn handle_gemini(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! 模型映射模块
|
||||||
|
//!
|
||||||
|
//! 在请求转发前,根据 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,74 +30,73 @@ 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)?;
|
|
||||||
|
|
||||||
// 1. 当前供应商始终第一位
|
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
if let Some(current) = all_providers.get(¤t_id) {
|
Ok(config) => {
|
||||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
let enabled = config.auto_failover_enabled;
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
|
||||||
|
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!(
|
||||||
// 2. 获取故障转移队列中的供应商
|
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||||
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 {
|
||||||
if !item.enabled {
|
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||||
continue;
|
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||||
}
|
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!(
|
||||||
"[{}] Failover provider available: {} ({}) at queue position {}",
|
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||||
app_type,
|
app_type,
|
||||||
provider.name,
|
provider.name,
|
||||||
provider.id,
|
provider.id,
|
||||||
item.queue_order
|
provider.sort_index
|
||||||
);
|
);
|
||||||
result.push(provider.clone());
|
result.push(provider);
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[{}] Failover provider {} circuit breaker open, skipping",
|
"[{}] Queue 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(¤t_id, app_type)? {
|
||||||
|
log::info!(
|
||||||
|
"[{}] Current provider: {} ({})",
|
||||||
|
app_type,
|
||||||
|
current.name,
|
||||||
|
current.id
|
||||||
|
);
|
||||||
|
result.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
@@ -107,7 +106,7 @@ impl ProviderRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] Failover chain: {} provider(s) available",
|
"[{}] Provider chain: {} provider(s) available",
|
||||||
app_type,
|
app_type,
|
||||||
result.len()
|
result.len()
|
||||||
);
|
);
|
||||||
@@ -138,9 +137,16 @@ impl ProviderRouter {
|
|||||||
success: bool,
|
success: bool,
|
||||||
error_msg: Option<String>,
|
error_msg: Option<String>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// 1. 获取熔断器配置(用于更新健康状态和判断是否禁用)
|
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
|
||||||
let config = self.db.get_circuit_breaker_config().await.ok();
|
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5);
|
Ok(app_config) => app_config.circuit_failure_threshold,
|
||||||
|
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}");
|
||||||
@@ -237,12 +243,34 @@ impl ProviderRouter {
|
|||||||
return breaker.clone();
|
return breaker.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从数据库加载配置
|
// 从 key 中提取 app_type (格式: "app_type:provider_id")
|
||||||
let config = self
|
let app_type = key.split(':').next().unwrap_or("claude");
|
||||||
.db
|
|
||||||
.get_circuit_breaker_config()
|
// 按应用独立读取熔断器配置
|
||||||
.await
|
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
.unwrap_or_default();
|
Ok(app_config) => {
|
||||||
|
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:?}");
|
||||||
|
|
||||||
@@ -264,25 +292,14 @@ 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 select_providers_does_not_consume_half_open_permit() {
|
async fn test_failover_disabled_uses_current_provider() {
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
// 配置:让熔断器 Open 后立刻进入 HalfOpen(timeout_seconds=0),并用 1 次失败就打开熔断器
|
|
||||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
|
||||||
failure_threshold: 1,
|
|
||||||
timeout_seconds: 0,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// 准备 2 个 Provider:A(当前)+ 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 =
|
||||||
@@ -293,19 +310,84 @@ mod tests {
|
|||||||
db.set_current_provider("claude", "a").unwrap();
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
db.add_to_failover_queue("claude", "b").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());
|
||||||
|
|
||||||
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
|
failure_threshold: 1,
|
||||||
|
timeout_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.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.add_to_failover_queue("claude", "a").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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ pub trait ProviderAdapter: Send + Sync {
|
|||||||
/// 是否需要格式转换
|
/// 是否需要格式转换
|
||||||
///
|
///
|
||||||
/// 默认返回 `false`(透传模式)。
|
/// 默认返回 `false`(透传模式)。
|
||||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
|
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `provider` - Provider 配置
|
/// * `provider` - Provider 配置
|
||||||
|
|||||||
@@ -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**: 需要 Anthropic ↔ OpenAI 格式转换
|
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
||||||
|
|
||||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -28,10 +28,8 @@ 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 let Ok(base_url) = self.extract_base_url(provider) {
|
if self.is_openrouter(provider) {
|
||||||
if base_url.contains("openrouter.ai") {
|
return ProviderType::OpenRouter;
|
||||||
return ProviderType::OpenRouter;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测 ClaudeAuth (仅 Bearer 认证)
|
// 检测 ClaudeAuth (仅 Bearer 认证)
|
||||||
@@ -50,6 +48,24 @@ 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
|
||||||
@@ -87,6 +103,14 @@ 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")
|
||||||
@@ -186,12 +210,13 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||||
// OpenRouter 使用 /v1/chat/completions
|
// NOTE:
|
||||||
if base_url.contains("openrouter.ai") {
|
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||||
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||||
}
|
//
|
||||||
|
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||||
|
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||||
|
|
||||||
// Anthropic 直连
|
|
||||||
format!(
|
format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
base_url.trim_end_matches('/'),
|
base_url.trim_end_matches('/'),
|
||||||
@@ -207,19 +232,24 @@ 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 => {
|
AuthStrategy::ClaudeAuth => request
|
||||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
}
|
.header("anthropic-version", "2023-06-01"),
|
||||||
// OpenRouter: Bearer
|
// OpenRouter: Bearer
|
||||||
AuthStrategy::Bearer => {
|
AuthStrategy::Bearer => request
|
||||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
.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 {
|
||||||
self.is_openrouter(provider)
|
// NOTE:
|
||||||
|
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
||||||
|
// Anthropic ↔ OpenAI 的格式转换。
|
||||||
|
//
|
||||||
|
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
||||||
|
self.is_openrouter_compat_enabled(_provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_request(
|
fn transform_request(
|
||||||
@@ -253,6 +283,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +315,21 @@ 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();
|
||||||
@@ -378,7 +424,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/chat/completions");
|
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -398,5 +444,13 @@ 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,17 +48,21 @@ pub enum ProviderType {
|
|||||||
Gemini,
|
Gemini,
|
||||||
/// Google Gemini CLI (OAuth Bearer)
|
/// Google Gemini CLI (OAuth Bearer)
|
||||||
GeminiCli,
|
GeminiCli,
|
||||||
/// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
|
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
|
||||||
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 {
|
||||||
matches!(self, ProviderType::OpenRouter)
|
match self {
|
||||||
|
ProviderType::OpenRouter => false,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取默认端点
|
/// 获取默认端点
|
||||||
@@ -205,6 +209,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +220,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,6 +394,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
//! 统一处理流式和非流式 API 响应
|
//! 统一处理流式和非流式 API 响应
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState,
|
handler_config::UsageParserConfig,
|
||||||
usage::parser::TokenUsage, ProxyError,
|
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||||
|
server::ProxyState,
|
||||||
|
usage::parser::TokenUsage,
|
||||||
|
ProxyError,
|
||||||
};
|
};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -17,6 +20,7 @@ use std::{
|
|||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
},
|
},
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -60,8 +64,12 @@ 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 logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector));
|
let timeout_config = ctx.streaming_timeout_config();
|
||||||
|
|
||||||
|
// 创建带日志和超时的透传流
|
||||||
|
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()
|
||||||
@@ -93,12 +101,16 @@ 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) {
|
||||||
let model = json_value
|
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||||
.get("model")
|
let model = if let Some(ref m) = usage.model {
|
||||||
.and_then(|m| m.as_str())
|
m.clone()
|
||||||
.unwrap_or(&ctx.request_model);
|
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
|
||||||
|
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 信息,跳过记录",
|
||||||
@@ -344,21 +356,60 @@ 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);
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
loop {
|
||||||
match chunk {
|
// 选择超时时间:首字节超时或静默期超时
|
||||||
Ok(bytes) => {
|
let timeout_duration = if is_first_chunk {
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -394,11 +445,15 @@ pub fn create_logged_passthrough_stream(
|
|||||||
|
|
||||||
yield Ok(bytes);
|
yield Ok(bytes);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Some(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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,33 +3,54 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,3 +147,47 @@ 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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ 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,
|
||||||
@@ -45,7 +51,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: None,
|
model,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +59,20 @@ 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) =
|
||||||
@@ -102,6 +117,7 @@ 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
|
||||||
@@ -141,6 +157,12 @@ 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,
|
||||||
@@ -152,7 +174,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: None,
|
model,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,12 +244,18 @@ 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: None,
|
model,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +350,7 @@ 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,
|
||||||
@@ -335,10 +364,60 @@ 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]
|
||||||
fn test_claude_stream_parsing() {
|
fn test_claude_stream_parsing() {
|
||||||
|
let events = vec![
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"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, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_stream_parsing_no_model() {
|
||||||
let events = vec![
|
let events = vec![
|
||||||
json!({
|
json!({
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
@@ -363,6 +442,7 @@ 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, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -481,6 +561,7 @@ 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
|
||||||
@@ -502,6 +583,7 @@ 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]
|
||||||
@@ -512,6 +594,7 @@ 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
|
||||||
@@ -530,5 +613,6 @@ 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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ 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)?;
|
||||||
crate::mcp::sync_enabled_to_codex(config)?;
|
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||||
|
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||||
|
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||||
|
|
||||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
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) {
|
||||||
|
|||||||
@@ -690,3 +690,140 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归合并 JSON:base 为底,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::database::Database;
|
|||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
|
use crate::services::provider::write_live_snapshot;
|
||||||
use 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;
|
||||||
@@ -41,31 +42,31 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 启动代理服务器
|
/// 启动代理服务器
|
||||||
///
|
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
|
||||||
/// - `persist_enabled = true`:将 `proxy_config.enabled` 持久化为启用(用于“总开关”)
|
// 1. 启动时自动设置 proxy_enabled = true
|
||||||
/// - `persist_enabled = false`:仅在当前进程启动代理服务(用于“按 App 接管”自动启动)
|
let mut global_config = self
|
||||||
pub async fn start(&self, persist_enabled: bool) -> Result<ProxyServerInfo, String> {
|
.db
|
||||||
// 1. 获取配置
|
.get_global_proxy_config()
|
||||||
let mut config = self
|
.await
|
||||||
|
.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,
|
||||||
@@ -86,14 +87,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -138,7 +131,7 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 启动代理服务器
|
// 5. 启动代理服务器
|
||||||
match self.start(true).await {
|
match self.start().await {
|
||||||
Ok(info) => Ok(info),
|
Ok(info) => Ok(info),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 启动失败,恢复原始配置
|
// 启动失败,恢复原始配置
|
||||||
@@ -159,54 +152,55 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
|
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
|
||||||
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
|
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
|
||||||
let claude = self
|
// 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测
|
||||||
|
let claude_enabled = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup("claude")
|
.get_proxy_config_for_app("claude")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取 Claude 接管状态失败: {e}"))?
|
.map(|c| c.enabled)
|
||||||
.is_some();
|
.unwrap_or(false);
|
||||||
let codex = self
|
let codex_enabled = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup("codex")
|
.get_proxy_config_for_app("codex")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取 Codex 接管状态失败: {e}"))?
|
.map(|c| c.enabled)
|
||||||
.is_some();
|
.unwrap_or(false);
|
||||||
let gemini = self
|
let gemini_enabled = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup("gemini")
|
.get_proxy_config_for_app("gemini")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))?
|
.map(|c| c.enabled)
|
||||||
.is_some();
|
.unwrap_or(false);
|
||||||
|
|
||||||
Ok(ProxyTakeoverStatus {
|
Ok(ProxyTakeoverStatus {
|
||||||
claude,
|
claude: claude_enabled,
|
||||||
codex,
|
codex: codex_enabled,
|
||||||
gemini,
|
gemini: gemini_enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 为指定应用开启/关闭 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(false).await?;
|
self.start().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 已接管则直接返回(幂等)
|
// 2) 已接管则直接返回(幂等)
|
||||||
if self
|
let current_config = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup(app_type_str)
|
.get_proxy_config_for_app(app_type_str)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
|
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||||
.is_some()
|
|
||||||
{
|
if current_config.enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,25 +216,46 @@ 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}");
|
||||||
let _ = self.restore_live_config_for_app(&app).await;
|
match self.restore_live_config_for_app(&app).await {
|
||||||
let _ = self.db.delete_live_backup(app_type_str).await;
|
Ok(()) => {
|
||||||
|
// 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源
|
||||||
|
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) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
// 6) 设置 proxy_config.enabled = true
|
||||||
|
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 has_backup = self
|
let current_config = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup(app_type_str)
|
.get_proxy_config_for_app(app_type_str)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
|
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||||
.is_some();
|
|
||||||
if !has_backup {
|
if !current_config.enabled {
|
||||||
return Ok(());
|
return Ok(()); // 未接管,幂等返回
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 恢复 Live 配置
|
// 1) 恢复 Live 配置
|
||||||
@@ -252,22 +267,36 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
|
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
|
||||||
|
|
||||||
// 3) 若无其它接管,更新旧标志,并在总开关未开启时停止代理服务
|
// 3) 设置 proxy_config.enabled = false
|
||||||
let has_any_backup = self
|
let mut updated_config = self
|
||||||
.db
|
.db
|
||||||
.has_any_live_backup()
|
.get_proxy_config_for_app(app_type_str)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("检查 Live 备份失败: {e}"))?;
|
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||||
if !has_any_backup {
|
updated_config.enabled = false;
|
||||||
|
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;
|
||||||
|
|
||||||
let master_enabled = self
|
if self.is_running().await {
|
||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -331,34 +360,35 @@ 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"
|
||||||
{
|
{
|
||||||
obj.insert(
|
let mut updated = false;
|
||||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
|
||||||
json!(token),
|
obj.insert(
|
||||||
);
|
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||||
obj.insert(
|
json!(token),
|
||||||
"ANTHROPIC_API_KEY".to_string(),
|
);
|
||||||
json!(token),
|
updated = true;
|
||||||
);
|
}
|
||||||
|
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"] = json!({
|
provider.settings_config["env"] =
|
||||||
token_key: token
|
json!({ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,10 +525,18 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
|
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
|
||||||
|
|
||||||
// 将 enabled 设为 false,避免下次启动时自动开启
|
// 停止时设置 proxy_enabled = false
|
||||||
if let Ok(mut config) = self.db.get_proxy_config().await {
|
let mut global_config = self
|
||||||
config.enabled = false;
|
.db
|
||||||
let _ = self.db.update_proxy_config(config).await;
|
.get_global_proxy_config()
|
||||||
|
.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!("代理服务器已停止");
|
||||||
@@ -508,47 +546,88 @@ 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. 清除接管状态
|
// 3. 清除 proxy_config 表中的接管状态(兼容旧版)
|
||||||
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. 删除备份
|
// 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)
|
||||||
|
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}"))?;
|
||||||
|
|
||||||
// 5. 重置健康状态(让健康徽章恢复为正常)
|
// 6. 重置健康状态(让健康徽章恢复为正常)
|
||||||
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
|
||||||
@@ -909,30 +988,270 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 恢复原始 Live 配置
|
/// 恢复原始 Live 配置
|
||||||
async fn restore_live_configs(&self) -> Result<(), String> {
|
async fn restore_live_configs(&self) -> Result<(), String> {
|
||||||
// Claude
|
let mut errors = Vec::new();
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
.map_err(|e| format!("解析 Claude 备份失败: {e}"))?;
|
if let Err(e) = self
|
||||||
self.write_claude_live(&config)?;
|
.restore_live_config_for_app_with_fallback(&app_type)
|
||||||
log::info!("Claude Live 配置已恢复");
|
.await
|
||||||
|
{
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codex
|
if errors.is_empty() {
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("codex").await {
|
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!("解析 Codex 备份失败: {e}"))?;
|
.map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?;
|
||||||
self.write_codex_live(&config)?;
|
self.write_live_config_for_app(app_type, &config)?;
|
||||||
log::info!("Codex Live 配置已恢复");
|
log::info!("{app_type_str} Live 配置已从备份恢复");
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemini
|
// 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await {
|
if !self.detect_takeover_in_live_config_for_app(app_type) {
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
return Ok(());
|
||||||
.map_err(|e| format!("解析 Gemini 备份失败: {e}"))?;
|
|
||||||
self.write_gemini_live(&config)?;
|
|
||||||
log::info!("Gemini Live 配置已恢复");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1) 优先从 SSOT(当前供应商)重建 Live(比“清理字段”更可用)
|
||||||
|
match self.restore_live_from_ssot_for_app(app_type) {
|
||||||
|
Ok(true) => {
|
||||||
|
log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
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(¤t_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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +1265,7 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 从异常退出中恢复(启动时调用)
|
/// 从异常退出中恢复(启动时调用)
|
||||||
///
|
///
|
||||||
/// 检测到 live_takeover_active=true 但代理未运行时调用此方法。
|
/// 检测到 Live 备份残留时调用此方法。
|
||||||
/// 会恢复 Live 配置、清除接管标志、删除备份。
|
/// 会恢复 Live 配置、清除接管标志、删除备份。
|
||||||
pub async fn recover_from_crash(&self) -> Result<(), String> {
|
pub async fn recover_from_crash(&self) -> Result<(), String> {
|
||||||
// 1. 恢复 Live 配置
|
// 1. 恢复 Live 配置
|
||||||
@@ -970,7 +1289,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() {
|
||||||
@@ -1255,9 +1574,8 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
||||||
|
|
||||||
// 保存到数据库(保持 enabled 和 live_takeover_active 状态不变)
|
// 保存到数据库(保持 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
|
||||||
@@ -1370,6 +1688,47 @@ 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() {
|
||||||
@@ -1432,4 +1791,116 @@ 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,6 +3,7 @@
|
|||||||
//! 使用流式 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;
|
||||||
@@ -141,15 +142,17 @@ 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, &config.claude_model).await
|
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await
|
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await
|
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -379,6 +382,48 @@ 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)]
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ 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,
|
||||||
@@ -65,12 +68,17 @@ 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,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-1",
|
"version": "3.9.0-2",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"titleBarStyle": "Visible"
|
"titleBarStyle": "Visible",
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,19 +76,8 @@ fn sync_codex_provider_writes_auth_and_config() {
|
|||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
let mut config = MultiAppConfig::default();
|
||||||
|
|
||||||
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
|
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
||||||
config.mcp.codex.servers.insert(
|
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
||||||
"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": {
|
||||||
@@ -133,9 +122,10 @@ 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("command = \"echo\""),
|
toml_text.contains("base_url"),
|
||||||
"config.toml should contain serialized enabled MCP server"
|
"config.toml should contain base_url from provider config"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 当前供应商应同步最新 config 文本
|
// 当前供应商应同步最新 config 文本
|
||||||
@@ -1003,3 +993,76 @@ 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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,
|
||||||
@@ -41,12 +43,25 @@ 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 = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
type View =
|
||||||
|
| "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");
|
||||||
@@ -58,6 +73,22 @@ function App() {
|
|||||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
|
// 保存最后一个有效的 provider,用于动画退出期间显示内容
|
||||||
|
const lastUsageProviderRef = useRef<Provider | null>(null);
|
||||||
|
const lastEditingProviderRef = useRef<Provider | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (usageProvider) {
|
||||||
|
lastUsageProviderRef.current = usageProvider;
|
||||||
|
}
|
||||||
|
}, [usageProvider]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingProvider) {
|
||||||
|
lastEditingProviderRef.current = editingProvider;
|
||||||
|
}
|
||||||
|
}, [editingProvider]);
|
||||||
|
|
||||||
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);
|
||||||
@@ -65,7 +96,20 @@ 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 { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus();
|
const {
|
||||||
|
isRunning: isProxyRunning,
|
||||||
|
takeoverStatus,
|
||||||
|
status: proxyStatus,
|
||||||
|
} = useProxyStatus();
|
||||||
|
// 当前应用的代理是否开启
|
||||||
|
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
||||||
|
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
||||||
|
const activeProviderId = useMemo(() => {
|
||||||
|
const target = proxyStatus?.active_targets?.find(
|
||||||
|
(t) => t.app_type === activeApp,
|
||||||
|
);
|
||||||
|
return target?.provider_id;
|
||||||
|
}, [proxyStatus?.active_targets, activeApp]);
|
||||||
|
|
||||||
// 获取供应商列表,当代理服务运行时自动刷新
|
// 获取供应商列表,当代理服务运行时自动刷新
|
||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
||||||
@@ -109,6 +153,38 @@ 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 () => {
|
||||||
@@ -186,6 +262,21 @@ 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 {
|
||||||
@@ -268,7 +359,20 @@ function App() {
|
|||||||
|
|
||||||
// 导入配置成功后刷新
|
// 导入配置成功后刷新
|
||||||
const handleImportSuccess = async () => {
|
const handleImportSuccess = async () => {
|
||||||
await refetch();
|
try {
|
||||||
|
// 导入会影响所有应用的供应商数据:刷新所有 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) {
|
||||||
@@ -277,79 +381,115 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (currentView) {
|
const content = (() => {
|
||||||
case "settings":
|
switch (currentView) {
|
||||||
return (
|
case "settings":
|
||||||
<SettingsPage
|
return (
|
||||||
open={true}
|
<SettingsPage
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
open={true}
|
||||||
onImportSuccess={handleImportSuccess}
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
/>
|
onImportSuccess={handleImportSuccess}
|
||||||
);
|
/>
|
||||||
case "prompts":
|
);
|
||||||
return (
|
case "prompts":
|
||||||
<PromptPanel
|
return (
|
||||||
ref={promptPanelRef}
|
<PromptPanel
|
||||||
open={true}
|
ref={promptPanelRef}
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
open={true}
|
||||||
appId={activeApp}
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
/>
|
appId={activeApp}
|
||||||
);
|
/>
|
||||||
case "skills":
|
);
|
||||||
return (
|
case "skills":
|
||||||
<SkillsPage
|
return (
|
||||||
ref={skillsPageRef}
|
<SkillsPage
|
||||||
onClose={() => setCurrentView("providers")}
|
ref={skillsPageRef}
|
||||||
initialApp={activeApp}
|
onClose={() => setCurrentView("providers")}
|
||||||
/>
|
initialApp={activeApp}
|
||||||
);
|
/>
|
||||||
case "mcp":
|
);
|
||||||
return (
|
case "mcp":
|
||||||
<UnifiedMcpPanel
|
return (
|
||||||
ref={mcpPanelRef}
|
<UnifiedMcpPanel
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
ref={mcpPanelRef}
|
||||||
/>
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
);
|
/>
|
||||||
case "agents":
|
);
|
||||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
case "agents":
|
||||||
default:
|
return (
|
||||||
return (
|
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
);
|
||||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
case "universal":
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
return (
|
||||||
<div className="space-y-4">
|
<div className="mx-auto max-w-[56rem] px-5 pt-4">
|
||||||
<ProviderList
|
<UniversalProviderPanel />
|
||||||
providers={providers}
|
</div>
|
||||||
currentProviderId={currentProviderId}
|
);
|
||||||
appId={activeApp}
|
default:
|
||||||
isLoading={isLoading}
|
return (
|
||||||
isProxyRunning={isProxyRunning}
|
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
isProxyTakeover={isProxyRunning && isTakeoverActive}
|
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||||
onSwitch={switchProvider}
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||||
onEdit={setEditingProvider}
|
<AnimatePresence mode="wait">
|
||||||
onDelete={setConfirmDelete}
|
<motion.div
|
||||||
onDuplicate={handleDuplicateProvider}
|
key={activeApp}
|
||||||
onConfigureUsage={setUsageProvider}
|
initial={{ opacity: 0 }}
|
||||||
onOpenWebsite={handleOpenWebsite}
|
animate={{ opacity: 1 }}
|
||||||
onCreate={() => setIsAddOpen(true)}
|
exit={{ opacity: 0 }}
|
||||||
/>
|
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 min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
|
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
|
||||||
style={{ overflowX: "hidden" }}
|
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
||||||
>
|
>
|
||||||
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
|
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
|
||||||
<div
|
<div
|
||||||
className="fixed top-0 left-0 right-0 h-4 z-[60]"
|
className="fixed top-0 left-0 right-0 z-[60]"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
|
||||||
/>
|
/>
|
||||||
{/* 环境变量警告横幅 */}
|
{/* 环境变量警告横幅 */}
|
||||||
{showEnvBanner && envConflicts.length > 0 && (
|
{showEnvBanner && envConflicts.length > 0 && (
|
||||||
@@ -379,13 +519,18 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<header
|
<header
|
||||||
className="fixed top-0 z-50 w-full py-3 bg-background/80 backdrop-blur-md transition-all duration-300"
|
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={
|
||||||
|
{
|
||||||
|
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 max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
|
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag" } as any}
|
||||||
>
|
>
|
||||||
@@ -401,7 +546,7 @@ function App() {
|
|||||||
onClick={() => setCurrentView("providers")}
|
onClick={() => setCurrentView("providers")}
|
||||||
className="mr-2 rounded-lg"
|
className="mr-2 rounded-lg"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="w-4 h-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")}
|
||||||
@@ -410,6 +555,10 @@ 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>
|
||||||
) : (
|
) : (
|
||||||
@@ -421,7 +570,7 @@ function App() {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xl font-semibold transition-colors",
|
"text-xl font-semibold transition-colors",
|
||||||
isProxyRunning && isTakeoverActive
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||||
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
||||||
)}
|
)}
|
||||||
@@ -435,7 +584,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="h-4 w-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
||||||
@@ -444,27 +593,27 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2 h-[32px]"
|
||||||
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={addActionButtonClass}
|
className={`ml-auto ${addActionButtonClass}`}
|
||||||
title={t("prompts.add")}
|
title={t("prompts.add")}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentView === "mcp" && (
|
{currentView === "mcp" && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||||
className={addActionButtonClass}
|
className={`ml-auto ${addActionButtonClass}`}
|
||||||
title={t("mcp.unifiedPanel.addServer")}
|
title={t("mcp.unifiedPanel.addServer")}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentView === "skills" && (
|
{currentView === "skills" && (
|
||||||
@@ -475,7 +624,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="h-4 w-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
{t("skills.refresh")}
|
{t("skills.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -484,7 +633,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="h-4 w-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
{t("skills.repoManager")}
|
{t("skills.repoManager")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -495,7 +644,7 @@ function App() {
|
|||||||
|
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
|
||||||
<div className="bg-muted p-1 rounded-xl flex items-center gap-1">
|
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -509,20 +658,20 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
title={t("skills.manage")}
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||||
</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="h-4 w-4" />
|
<Bot className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)} */}
|
)} */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -530,7 +679,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="h-4 w-4" />
|
<Book className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -539,7 +688,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="h-4 w-4" />
|
<Server className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -548,7 +697,7 @@ function App() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className={`ml-2 ${addActionButtonClass}`}
|
className={`ml-2 ${addActionButtonClass}`}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -556,13 +705,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main
|
<main className="flex-1 pb-12 animate-fade-in ">
|
||||||
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
|
<div className="pb-12">{renderContent()}</div>
|
||||||
currentView === "providers" ? "pt-24" : "pt-20"
|
|
||||||
}`}
|
|
||||||
style={{ overflowX: "hidden" }}
|
|
||||||
>
|
|
||||||
{renderContent()}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddProviderDialog
|
<AddProviderDialog
|
||||||
@@ -574,7 +718,7 @@ function App() {
|
|||||||
|
|
||||||
<EditProviderDialog
|
<EditProviderDialog
|
||||||
open={Boolean(editingProvider)}
|
open={Boolean(editingProvider)}
|
||||||
provider={editingProvider}
|
provider={lastEditingProviderRef.current}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
@@ -582,17 +726,19 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleEditProvider}
|
onSubmit={handleEditProvider}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isProxyTakeover={isProxyRunning && isTakeoverActive}
|
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{usageProvider && (
|
{lastUsageProviderRef.current && (
|
||||||
<UsageScriptModal
|
<UsageScriptModal
|
||||||
provider={usageProvider}
|
provider={lastUsageProviderRef.current}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isOpen={Boolean(usageProvider)}
|
isOpen={Boolean(usageProvider)}
|
||||||
onClose={() => setUsageProvider(null)}
|
onClose={() => setUsageProvider(null)}
|
||||||
onSave={(script) => {
|
onSave={(script) => {
|
||||||
void saveUsageScript(usageProvider, script);
|
if (usageProvider) {
|
||||||
|
void saveUsageScript(usageProvider, script);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex bg-muted rounded-lg p-1 gap-1">
|
<div className="inline-flex bg-muted rounded-xl 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 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 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 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 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 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 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"
|
||||||
|
|||||||
@@ -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-gray-400 dark:text-gray-500" />
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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-gray-400 dark:text-gray-500 flex items-center gap-1">
|
<span className="text-[10px] text-muted-foreground/70 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-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
|
||||||
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-gray-400 dark:text-gray-500 flex items-center gap-1">
|
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
{formatRelativeTime(lastQueriedAt, now, t)}
|
{formatRelativeTime(lastQueriedAt, now, t)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -32,46 +33,57 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<AnimatePresence>
|
||||||
className="fixed inset-0 z-[60] flex flex-col"
|
{isOpen && (
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
<motion.div
|
||||||
>
|
initial={{ opacity: 0 }}
|
||||||
{/* Header */}
|
animate={{ opacity: 1 }}
|
||||||
<div
|
exit={{ opacity: 0 }}
|
||||||
className="flex-shrink-0 py-3 border-b border-border-default"
|
transition={{ duration: 0.2 }}
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
className="fixed inset-0 z-[60] flex flex-col"
|
||||||
>
|
|
||||||
<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))" }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
|
{/* Header */}
|
||||||
{footer}
|
<div
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</AnimatePresence>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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-gray-900 dark:text-gray-100 cursor-pointer"
|
className="block text-sm font-medium text-foreground cursor-pointer"
|
||||||
>
|
>
|
||||||
{conflict.varName}
|
{conflict.varName}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
|
<p className="text-xs text-muted-foreground mt-1 break-all">
|
||||||
{t("env.field.value")}: {conflict.varValue}
|
{t("env.field.value")}: {conflict.varValue}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("env.field.source")}:{" "}
|
{t("env.field.source")}:{" "}
|
||||||
{getSourceDescription(conflict)}
|
{getSourceDescription(conflict)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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-gray-500 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<span className="text-sm text-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<span className="text-sm text-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<span className="text-sm text-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||||
{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-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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Env */}
|
{/* Env */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||||
{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-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"
|
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"
|
||||||
/>
|
/>
|
||||||
</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-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||||
{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-gray-900 dark:text-gray-100">
|
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||||
{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-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"
|
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"
|
||||||
/>
|
/>
|
||||||
</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-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-medium text-foreground">
|
||||||
{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">
|
||||||
|
|||||||
@@ -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-gray-500 dark:text-gray-400">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
{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-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||||
<Server size={24} className="text-gray-400 dark:text-gray-500" />
|
<Server size={24} className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
{t("mcp.unifiedPanel.noServers")}
|
{t("mcp.unifiedPanel.noServers")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("mcp.emptyDescription")}
|
{t("mcp.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,9 +237,7 @@ 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-gray-900 dark:text-gray-100">
|
<h3 className="font-medium text-foreground">{name}</h3>
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
{docsUrl && (
|
{docsUrl && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -253,12 +251,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!description && tags && tags.length > 0 && (
|
{!description && tags && tags.length > 0 && (
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
<p className="text-xs text-muted-foreground/70 truncate">
|
||||||
{tags.join(", ")}
|
{tags.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -269,7 +267,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-gray-700 dark:text-gray-300 cursor-pointer"
|
className="text-sm text-foreground/80 cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("mcp.unifiedPanel.apps.claude")}
|
{t("mcp.unifiedPanel.apps.claude")}
|
||||||
</label>
|
</label>
|
||||||
@@ -285,7 +283,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-gray-700 dark:text-gray-300 cursor-pointer"
|
className="text-sm text-foreground/80 cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("mcp.unifiedPanel.apps.codex")}
|
{t("mcp.unifiedPanel.apps.codex")}
|
||||||
</label>
|
</label>
|
||||||
@@ -301,7 +299,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-gray-700 dark:text-gray-300 cursor-pointer"
|
className="text-sm text-foreground/80 cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("mcp.unifiedPanel.apps.gemini")}
|
{t("mcp.unifiedPanel.apps.gemini")}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -36,11 +36,9 @@ 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-gray-900 dark:text-gray-100 mb-1">
|
<h3 className="font-medium text-foreground mb-1">{prompt.name}</h3>
|
||||||
{prompt.name}
|
|
||||||
</h3>
|
|
||||||
{prompt.description && (
|
{prompt.description && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{prompt.description}
|
{prompt.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -108,21 +108,18 @@ 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-gray-500 dark:text-gray-400">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
{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-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||||
<FileText
|
<FileText size={24} className="text-muted-foreground" />
|
||||||
size={24}
|
|
||||||
className="text-gray-400 dark:text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
{t("prompts.empty")}
|
{t("prompts.empty")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("prompts.emptyDescription")}
|
{t("prompts.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useState } 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 } from "@/types";
|
import type { Provider, CustomEndpoint, UniversalProvider } 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;
|
||||||
@@ -27,6 +33,46 @@ 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) => {
|
||||||
@@ -156,46 +202,86 @@ export function AddProviderDialog({
|
|||||||
[appId, onSubmit, onOpenChange],
|
[appId, onSubmit, onOpenChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitLabel =
|
// 动态 footer:根据当前 Tab 显示不同按钮
|
||||||
appId === "claude"
|
const footer =
|
||||||
? t("provider.addClaudeProvider")
|
activeTab === "app-specific" ? (
|
||||||
: appId === "codex"
|
<>
|
||||||
? t("provider.addCodexProvider")
|
<Button
|
||||||
: t("provider.addGeminiProvider");
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
const footer = (
|
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||||
<>
|
>
|
||||||
<Button
|
{t("common.cancel")}
|
||||||
variant="outline"
|
</Button>
|
||||||
onClick={() => onOpenChange(false)}
|
<Button
|
||||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
type="submit"
|
||||||
>
|
form="provider-form"
|
||||||
{t("common.cancel")}
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
</Button>
|
>
|
||||||
<Button
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
type="submit"
|
{t("common.add")}
|
||||||
form="provider-form"
|
</Button>
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
</>
|
||||||
>
|
) : (
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<>
|
||||||
{t("common.add")}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
</>
|
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={submitLabel}
|
title={t("provider.addNewProvider")}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
>
|
>
|
||||||
<ProviderForm
|
<Tabs
|
||||||
appId={appId}
|
value={activeTab}
|
||||||
submitLabel={t("common.add")}
|
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||||
onSubmit={handleSubmit}
|
>
|
||||||
onCancel={() => onOpenChange(false)}
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
showButtons={false}
|
<TabsTrigger value="app-specific">
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
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;
|
||||||
@@ -11,17 +12,20 @@ interface HealthStatusIndicatorProps {
|
|||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
operational: {
|
operational: {
|
||||||
color: "bg-emerald-500",
|
color: "bg-emerald-500",
|
||||||
label: "正常",
|
labelKey: "health.operational",
|
||||||
|
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",
|
||||||
label: "降级",
|
labelKey: "health.degraded",
|
||||||
|
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",
|
||||||
label: "失败",
|
labelKey: "health.failed",
|
||||||
|
labelFallback: "失败",
|
||||||
textColor: "text-red-600 dark:text-red-400",
|
textColor: "text-red-600 dark:text-red-400",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -31,13 +35,15 @@ 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)}>
|
||||||
{config.label}
|
{label}
|
||||||
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
|
Plus,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -22,6 +23,10 @@ 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({
|
||||||
@@ -34,38 +39,88 @@ 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={isCurrent ? "secondary" : "default"}
|
variant={buttonState.variant}
|
||||||
onClick={onSwitch}
|
onClick={handleMainButtonClick}
|
||||||
disabled={isCurrent}
|
disabled={buttonState.disabled}
|
||||||
className={cn(
|
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isCurrent ? (
|
{buttonState.icon}
|
||||||
<>
|
{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">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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";
|
||||||
|
|
||||||
@@ -36,6 +37,12 @@ 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) => {
|
||||||
@@ -88,6 +95,12 @@ 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();
|
||||||
|
|
||||||
@@ -148,21 +161,32 @@ 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 时的边框效果
|
||||||
isProxyTakeover
|
isAutoFailoverEnabled || isProxyTakeover
|
||||||
? "hover:border-emerald-500/50"
|
? "hover:border-emerald-500/50"
|
||||||
: "hover:border-border-active",
|
: "hover:border-border-active",
|
||||||
// 代理接管模式下当前供应商使用绿色边框
|
// 当前激活的供应商边框样式
|
||||||
isProxyTakeover && isCurrent
|
shouldUseGreen &&
|
||||||
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
|
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||||
: isCurrent
|
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||||
? "border-primary/50 shadow-sm"
|
!isActiveProvider && "hover: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",
|
||||||
)}
|
)}
|
||||||
@@ -170,11 +194,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",
|
||||||
// 代理接管模式下使用绿色渐变,否则使用蓝色主色调
|
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
||||||
isProxyTakeover && isCurrent
|
shouldUseGreen && "from-emerald-500/10",
|
||||||
? "from-emerald-500/10"
|
shouldUseBlue && "from-blue-500/10",
|
||||||
: "from-primary/10",
|
!isActiveProvider && "from-primary/10",
|
||||||
isCurrent ? "opacity-100" : "opacity-0",
|
isActiveProvider ? "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">
|
||||||
@@ -209,13 +233,20 @@ export function ProviderCard({
|
|||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 健康状态徽章和优先级 */}
|
{/* 健康状态徽章 */}
|
||||||
{isProxyRunning && health && (
|
{isProxyRunning && isInFailoverQueue && 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
|
||||||
@@ -308,6 +339,10 @@ 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,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -14,11 +15,14 @@ export function ProviderHealthBadge({
|
|||||||
consecutiveFailures,
|
consecutiveFailures,
|
||||||
className,
|
className,
|
||||||
}: ProviderHealthBadgeProps) {
|
}: ProviderHealthBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 根据失败次数计算状态
|
// 根据失败次数计算状态
|
||||||
const getStatus = () => {
|
const getStatus = () => {
|
||||||
if (consecutiveFailures === 0) {
|
if (consecutiveFailures === 0) {
|
||||||
return {
|
return {
|
||||||
label: "正常",
|
labelKey: "health.operational",
|
||||||
|
labelFallback: "正常",
|
||||||
status: ProviderHealthStatus.Healthy,
|
status: ProviderHealthStatus.Healthy,
|
||||||
color: "bg-green-500",
|
color: "bg-green-500",
|
||||||
// 使用更深/柔和的背景色,去除可能的白色内容感
|
// 使用更深/柔和的背景色,去除可能的白色内容感
|
||||||
@@ -27,7 +31,8 @@ export function ProviderHealthBadge({
|
|||||||
};
|
};
|
||||||
} else if (consecutiveFailures < 5) {
|
} else if (consecutiveFailures < 5) {
|
||||||
return {
|
return {
|
||||||
label: "降级",
|
labelKey: "health.degraded",
|
||||||
|
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",
|
||||||
@@ -35,7 +40,8 @@ export function ProviderHealthBadge({
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
label: "熔断",
|
labelKey: "health.circuitOpen",
|
||||||
|
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",
|
||||||
@@ -45,6 +51,9 @@ export function ProviderHealthBadge({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = getStatus();
|
const statusConfig = getStatus();
|
||||||
|
const label = t(statusConfig.labelKey, {
|
||||||
|
defaultValue: statusConfig.labelFallback,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -54,10 +63,13 @@ export function ProviderHealthBadge({
|
|||||||
statusConfig.textColor,
|
statusConfig.textColor,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
title={`连续失败 ${consecutiveFailures} 次`}
|
title={t("health.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>{statusConfig.label}</span>
|
<span>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,31 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import type { CSSProperties } from "react";
|
import {
|
||||||
|
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>;
|
||||||
@@ -27,6 +45,7 @@ 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({
|
||||||
@@ -41,9 +60,11 @@ export function ProviderList({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isProxyRunning = false, // 默认值为 false
|
isProxyRunning = false,
|
||||||
isProxyTakeover = false, // 默认值为 false
|
isProxyTakeover = false,
|
||||||
|
activeProviderId,
|
||||||
}: ProviderListProps) {
|
}: ProviderListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||||
providers,
|
providers,
|
||||||
appId,
|
appId,
|
||||||
@@ -52,17 +73,103 @@ 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="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
|
className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,21 +180,18 @@ export function ProviderList({
|
|||||||
return <ProviderEmptyState onCreate={onCreate} />;
|
return <ProviderEmptyState onCreate={onCreate} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderProviderList = () => (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={sortedProviders.map((provider) => provider.id)}
|
items={filteredProviders.map((provider) => provider.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div
|
<div className="space-y-3">
|
||||||
className="space-y-3 animate-slide-up"
|
{filteredProviders.map((provider) => (
|
||||||
style={{ animationDelay: "0.1s" }}
|
|
||||||
>
|
|
||||||
{sortedProviders.map((provider) => (
|
|
||||||
<SortableProviderCard
|
<SortableProviderCard
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
@@ -103,12 +207,98 @@ 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 {
|
||||||
@@ -125,6 +315,12 @@ 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({
|
||||||
@@ -141,6 +337,11 @@ function SortableProviderCard({
|
|||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover,
|
isProxyTakeover,
|
||||||
|
isAutoFailoverEnabled,
|
||||||
|
failoverPriority,
|
||||||
|
isInFailoverQueue,
|
||||||
|
onToggleFailover,
|
||||||
|
activeProviderId,
|
||||||
}: SortableProviderCardProps) {
|
}: SortableProviderCardProps) {
|
||||||
const {
|
const {
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
@@ -179,6 +380,12 @@ function SortableProviderCard({
|
|||||||
listeners,
|
listeners,
|
||||||
isDragging,
|
isDragging,
|
||||||
}}
|
}}
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
|
failoverPriority={failoverPriority}
|
||||||
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
onToggleFailover={onToggleFailover}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,16 +30,13 @@ 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-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
|
||||||
: "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"
|
: "border-border-default bg-background text-foreground 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
|
<label htmlFor={id} className="block text-sm font-medium text-foreground">
|
||||||
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">
|
||||||
@@ -58,7 +55,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-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground 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,7 +52,15 @@ 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={currentIcon ? "点击更换图标" : "点击选择图标"}
|
title={
|
||||||
|
currentIcon
|
||||||
|
? t("providerIcon.clickToChange", {
|
||||||
|
defaultValue: "点击更换图标",
|
||||||
|
})
|
||||||
|
: t("providerIcon.clickToSelect", {
|
||||||
|
defaultValue: "点击选择图标",
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
icon={currentIcon}
|
icon={currentIcon}
|
||||||
@@ -145,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
|
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://" />
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t("providerForm.websiteUrlPlaceholder")}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
@@ -39,12 +40,14 @@ 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",
|
||||||
@@ -53,6 +56,11 @@ 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({
|
||||||
@@ -77,11 +85,15 @@ 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();
|
||||||
|
|
||||||
@@ -162,6 +174,28 @@ 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">
|
||||||
@@ -185,6 +219,27 @@ 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-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
{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-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{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-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
{t("codexConfig.configToml")}
|
{t("codexConfig.configToml")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground 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-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{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-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
{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 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"
|
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"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{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-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
{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-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{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-gray-900 dark:text-gray-100"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
{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-gray-500 dark:text-gray-400 cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground 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-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("geminiConfig.configJsonHint", {
|
{t("geminiConfig.configJsonHint", {
|
||||||
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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";
|
||||||
@@ -72,6 +73,8 @@ 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;
|
||||||
@@ -91,6 +94,8 @@ export function ProviderForm({
|
|||||||
submitLabel,
|
submitLabel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onUniversalPresetSelect,
|
||||||
|
onManageUniversalProviders,
|
||||||
initialData,
|
initialData,
|
||||||
showButtons = true,
|
showButtons = true,
|
||||||
}: ProviderFormProps) {
|
}: ProviderFormProps) {
|
||||||
@@ -162,6 +167,8 @@ export function ProviderForm({
|
|||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const settingsConfigValue = form.watch("settingsConfig");
|
||||||
|
|
||||||
// 使用 API Key hook
|
// 使用 API Key hook
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -187,9 +194,10 @@ export function ProviderForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型)
|
// 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型)
|
||||||
const {
|
const {
|
||||||
claudeModel,
|
claudeModel,
|
||||||
|
reasoningModel,
|
||||||
defaultHaikuModel,
|
defaultHaikuModel,
|
||||||
defaultSonnetModel,
|
defaultSonnetModel,
|
||||||
defaultOpusModel,
|
defaultOpusModel,
|
||||||
@@ -199,6 +207,53 @@ 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,
|
||||||
@@ -753,6 +808,8 @@ export function ProviderForm({
|
|||||||
categoryKeys={categoryKeys}
|
categoryKeys={categoryKeys}
|
||||||
presetCategoryLabels={presetCategoryLabels}
|
presetCategoryLabels={presetCategoryLabels}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
|
onUniversalPresetSelect={onUniversalPresetSelect}
|
||||||
|
onManageUniversalProviders={onManageUniversalProviders}
|
||||||
category={category}
|
category={category}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -789,11 +846,15 @@ 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,11 +1,16 @@
|
|||||||
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 } from "lucide-react";
|
import { Zap, Star, Layers, Settings2 } 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;
|
||||||
@@ -18,6 +23,8 @@ 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; // 当前选中的分类
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +34,8 @@ export function ProviderPresetSelector({
|
|||||||
categoryKeys,
|
categoryKeys,
|
||||||
presetCategoryLabels,
|
presetCategoryLabels,
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
|
onUniversalPresetSelect,
|
||||||
|
onManageUniversalProviders,
|
||||||
category,
|
category,
|
||||||
}: ProviderPresetSelectorProps) {
|
}: ProviderPresetSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -149,10 +158,7 @@ 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] ??
|
presetCategoryLabels[category] ?? t("providerPreset.other")
|
||||||
t("providerPreset.categoryOther", {
|
|
||||||
defaultValue: "其他",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{renderPresetIcon(entry.preset)}
|
{renderPresetIcon(entry.preset)}
|
||||||
@@ -167,6 +173,49 @@ 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,13 +7,14 @@ interface UseModelStateProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理模型选择状态
|
* 管理模型选择状态
|
||||||
* 支持 ANTHROPIC_MODEL 和 ANTHROPIC_SMALL_FAST_MODEL
|
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_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("");
|
||||||
@@ -29,6 +30,10 @@ 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
|
||||||
@@ -47,6 +52,7 @@ 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 || "");
|
||||||
@@ -59,12 +65,14 @@ 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")
|
||||||
@@ -98,6 +106,8 @@ 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-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Zap className="h-3.5 w-3.5" />
|
<Zap className="h-3.5 w-3.5" />
|
||||||
{manageButtonLabel || defaultManageLabel}
|
{manageButtonLabel || defaultManageLabel}
|
||||||
|
|||||||
@@ -6,51 +6,67 @@ 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 {
|
import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy";
|
||||||
useCircuitBreakerConfig,
|
|
||||||
useUpdateCircuitBreakerConfig,
|
|
||||||
} from "@/lib/query/failover";
|
|
||||||
|
|
||||||
export interface AutoFailoverConfigPanelProps {
|
export interface AutoFailoverConfigPanelProps {
|
||||||
enabled: boolean;
|
appType: string;
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoFailoverConfigPanel({
|
export function AutoFailoverConfigPanel({
|
||||||
enabled,
|
appType,
|
||||||
onEnabledChange: _onEnabledChange,
|
disabled = false,
|
||||||
}: 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 } = useCircuitBreakerConfig();
|
const { data: config, isLoading, error } = useAppProxyConfig(appType);
|
||||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
const updateConfig = useUpdateAppProxyConfig();
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
failureThreshold: 5,
|
autoFailoverEnabled: false,
|
||||||
successThreshold: 2,
|
maxRetries: 3,
|
||||||
timeoutSeconds: 60,
|
streamingFirstByteTimeout: 30,
|
||||||
errorRateThreshold: 0.5,
|
streamingIdleTimeout: 60,
|
||||||
minRequests: 10,
|
nonStreamingTimeout: 300,
|
||||||
|
circuitFailureThreshold: 5,
|
||||||
|
circuitSuccessThreshold: 2,
|
||||||
|
circuitTimeoutSeconds: 60,
|
||||||
|
circuitErrorRateThreshold: 0.5,
|
||||||
|
circuitMinRequests: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...config,
|
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||||
|
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({
|
||||||
failureThreshold: formData.failureThreshold,
|
appType,
|
||||||
successThreshold: formData.successThreshold,
|
enabled: config.enabled,
|
||||||
timeoutSeconds: formData.timeoutSeconds,
|
autoFailoverEnabled: formData.autoFailoverEnabled,
|
||||||
errorRateThreshold: formData.errorRateThreshold,
|
maxRetries: formData.maxRetries,
|
||||||
minRequests: formData.minRequests,
|
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
|
||||||
|
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", "自动故障转移配置已保存"),
|
||||||
@@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...config,
|
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -79,16 +104,10 @@ 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">
|
||||||
@@ -114,22 +133,48 @@ 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="failureThreshold">
|
<Label htmlFor={`maxRetries-${appType}`}>
|
||||||
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
|
{t("proxy.autoFailover.maxRetries", "最大重试次数")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="failureThreshold"
|
id={`maxRetries-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="0"
|
||||||
max="20"
|
max="10"
|
||||||
value={formData.failureThreshold}
|
value={formData.maxRetries}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
failureThreshold: parseInt(e.target.value) || 5,
|
maxRetries: parseInt(e.target.value) || 3,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
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", "失败阈值")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`failureThreshold-${appType}`}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={formData.circuitFailureThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
circuitFailureThreshold: parseInt(e.target.value) || 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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.timeoutSettings", "超时配置")}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="timeoutSeconds">
|
<Label htmlFor={`streamingFirstByte-${appType}`}>
|
||||||
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
|
{t(
|
||||||
|
"proxy.autoFailover.streamingFirstByte",
|
||||||
|
"流式首字节超时(秒)",
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="timeoutSeconds"
|
id={`streamingFirstByte-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="10"
|
min="0"
|
||||||
max="300"
|
max="180"
|
||||||
value={formData.timeoutSeconds}
|
value={formData.streamingFirstByteTimeout}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
timeoutSeconds: parseInt(e.target.value) || 60,
|
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.autoFailover.timeoutHint",
|
"proxy.autoFailover.streamingFirstByteHint",
|
||||||
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
|
"等待首个数据块的最大时间",
|
||||||
|
)}
|
||||||
|
</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>
|
</p>
|
||||||
</div>
|
</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.circuitBreakerSettings", "熔断器高级设置")}
|
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器配置")}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="successThreshold">
|
<Label htmlFor={`successThreshold-${appType}`}>
|
||||||
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
|
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="successThreshold"
|
id={`successThreshold-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
value={formData.successThreshold}
|
value={formData.circuitSuccessThreshold}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
successThreshold: parseInt(e.target.value) || 2,
|
circuitSuccessThreshold: parseInt(e.target.value) || 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -201,23 +310,50 @@ export function AutoFailoverConfigPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="errorRateThreshold">
|
<Label htmlFor={`timeoutSeconds-${appType}`}>
|
||||||
|
{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"
|
id={`errorRateThreshold-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
value={Math.round(formData.errorRateThreshold * 100)}
|
value={Math.round(formData.circuitErrorRateThreshold * 100)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
circuitErrorRateThreshold:
|
||||||
|
(parseInt(e.target.value) || 50) / 100,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="minRequests">
|
<Label htmlFor={`minRequests-${appType}`}>
|
||||||
{t("proxy.autoFailover.minRequests", "最小请求数")}
|
{t("proxy.autoFailover.minRequests", "最小请求数")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="minRequests"
|
id={`minRequests-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="5"
|
min="5"
|
||||||
max="100"
|
max="100"
|
||||||
value={formData.minRequests}
|
value={formData.circuitMinRequests}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
minRequests: parseInt(e.target.value) || 10,
|
circuitMinRequests: parseInt(e.target.value) || 10,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -257,17 +393,10 @@ export function AutoFailoverConfigPanel({
|
|||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<Button
|
<Button variant="outline" onClick={handleReset} disabled={isDisabled}>
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={updateConfig.isPending || !enabled}
|
|
||||||
>
|
|
||||||
{t("common.reset", "重置")}
|
{t("common.reset", "重置")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSave} disabled={isDisabled}>
|
||||||
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" />
|
||||||
@@ -281,59 +410,6 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,37 +2,14 @@
|
|||||||
* 故障转移队列管理组件
|
* 故障转移队列管理组件
|
||||||
*
|
*
|
||||||
* 允许用户管理代理模式下的故障转移队列,支持:
|
* 允许用户管理代理模式下的故障转移队列,支持:
|
||||||
* - 拖拽排序
|
|
||||||
* - 添加/移除供应商
|
* - 添加/移除供应商
|
||||||
* - 启用/禁用队列项
|
* - 队列顺序基于首页供应商列表的 sort_index
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState } 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 {
|
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
|
||||||
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";
|
||||||
@@ -51,8 +28,8 @@ import {
|
|||||||
useAvailableProvidersForFailover,
|
useAvailableProvidersForFailover,
|
||||||
useAddToFailoverQueue,
|
useAddToFailoverQueue,
|
||||||
useRemoveFromFailoverQueue,
|
useRemoveFromFailoverQueue,
|
||||||
useReorderFailoverQueue,
|
useAutoFailoverEnabled,
|
||||||
useSetFailoverItemEnabled,
|
useSetAutoFailoverEnabled,
|
||||||
} from "@/lib/query/failover";
|
} from "@/lib/query/failover";
|
||||||
|
|
||||||
interface FailoverQueueManagerProps {
|
interface FailoverQueueManagerProps {
|
||||||
@@ -67,6 +44,10 @@ 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,
|
||||||
@@ -79,59 +60,11 @@ export function FailoverQueueManager({
|
|||||||
// Mutations
|
// Mutations
|
||||||
const addToQueue = useAddToFailoverQueue();
|
const addToQueue = useAddToFailoverQueue();
|
||||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||||
const reorderQueue = useReorderFailoverQueue();
|
|
||||||
const setItemEnabled = useSetFailoverItemEnabled();
|
|
||||||
|
|
||||||
// 拖拽配置
|
// 切换故障转移开关
|
||||||
const sensors = useSensors(
|
const handleToggleFailover = (enabled: boolean) => {
|
||||||
useSensor(PointerSensor, {
|
setFailoverEnabled.mutate({ appType, enabled });
|
||||||
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 () => {
|
||||||
@@ -171,19 +104,6 @@ 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">
|
||||||
@@ -203,13 +123,41 @@ 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>
|
||||||
@@ -260,7 +208,7 @@ export function FailoverQueueManager({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 队列列表 */}
|
{/* 队列列表 */}
|
||||||
{sortedQueue.length === 0 ? (
|
{!queue || queue.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(
|
||||||
@@ -270,39 +218,26 @@ export function FailoverQueueManager({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<div className="space-y-2">
|
||||||
sensors={sensors}
|
{queue.map((item, index) => (
|
||||||
collisionDetection={closestCenter}
|
<QueueItem
|
||||||
onDragEnd={handleDragEnd}
|
key={item.providerId}
|
||||||
>
|
item={item}
|
||||||
<SortableContext
|
index={index}
|
||||||
items={sortedQueue.map((item) => item.providerId)}
|
disabled={disabled}
|
||||||
strategy={verticalListSortingStrategy}
|
onRemove={handleRemoveProvider}
|
||||||
>
|
isRemoving={removeFromQueue.isPending}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
{sortedQueue.map((item, index) => (
|
))}
|
||||||
<SortableQueueItem
|
</div>
|
||||||
key={item.providerId}
|
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
disabled={disabled}
|
|
||||||
onToggleEnabled={handleToggleEnabled}
|
|
||||||
onRemove={handleRemoveProvider}
|
|
||||||
isRemoving={removeFromQueue.isPending}
|
|
||||||
isToggling={setItemEnabled.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 队列说明 */}
|
{/* 队列说明 */}
|
||||||
{sortedQueue.length > 0 && (
|
{queue && queue.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.failoverQueue.dragHint",
|
"proxy.failoverQueue.orderHint",
|
||||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -310,65 +245,29 @@ export function FailoverQueueManager({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableQueueItemProps {
|
interface QueueItemProps {
|
||||||
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 SortableQueueItem({
|
function QueueItem({
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
disabled,
|
disabled,
|
||||||
onToggleEnabled,
|
|
||||||
onRemove,
|
onRemove,
|
||||||
isRemoving,
|
isRemoving,
|
||||||
isToggling,
|
}: QueueItemProps) {
|
||||||
}: 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}
|
||||||
@@ -376,24 +275,11 @@ function SortableQueueItem({
|
|||||||
|
|
||||||
{/* 供应商名称 */}
|
{/* 供应商名称 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span
|
<span className="text-sm font-medium truncate block">
|
||||||
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"
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Clock,
|
Clock,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Server,
|
Server,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Settings,
|
Save,
|
||||||
|
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]);
|
||||||
|
|
||||||
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
|
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
|
||||||
// 当前供应商始终优先,队列仅用于失败后的备用顺序
|
// 当前供应商始终优先,队列仅用于失败后的备用顺序
|
||||||
@@ -26,6 +56,69 @@ 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);
|
||||||
@@ -47,18 +140,11 @@ 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>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
<p className="text-xs text-muted-foreground">服务地址</p>
|
{t("proxy.panel.serviceAddress", {
|
||||||
<Button
|
defaultValue: "服务地址",
|
||||||
size="sm"
|
})}
|
||||||
variant="ghost"
|
</p>
|
||||||
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}
|
||||||
@@ -70,16 +156,28 @@ export function ProxyPanel() {
|
|||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`http://${status.address}:${status.port}`,
|
`http://${status.address}:${status.port}`,
|
||||||
);
|
);
|
||||||
toast.success("地址已复制", { closeButton: true });
|
toast.success(
|
||||||
|
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>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{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) => (
|
||||||
@@ -101,18 +199,79 @@ 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">
|
||||||
当前 Provider:{" "}
|
{t("proxy.panel.currentProvider", {
|
||||||
|
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">
|
||||||
当前 Provider:等待首次请求…
|
{t("proxy.panel.waitingFirstRequest", {
|
||||||
|
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 ||
|
||||||
@@ -121,7 +280,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>
|
||||||
|
|
||||||
@@ -130,13 +289,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="claude"
|
appType="claude"
|
||||||
appLabel="Claude"
|
appLabel="Claude"
|
||||||
targets={claudeQueue
|
targets={claudeQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -146,13 +302,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="codex"
|
appType="codex"
|
||||||
appLabel="Codex"
|
appLabel="Codex"
|
||||||
targets={codexQueue
|
targets={codexQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -162,13 +315,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="gemini"
|
appType="gemini"
|
||||||
appLabel="Gemini"
|
appLabel="Gemini"
|
||||||
targets={geminiQueue
|
targets={geminiQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -179,52 +329,139 @@ 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="活跃连接"
|
label={t("proxy.panel.stats.activeConnections", {
|
||||||
|
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="总请求数"
|
label={t("proxy.panel.stats.totalRequests", {
|
||||||
|
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="成功率"
|
label={t("proxy.panel.stats.successRate", {
|
||||||
|
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="运行时间"
|
label={t("proxy.panel.stats.uptime", {
|
||||||
|
defaultValue: "运行时间",
|
||||||
|
})}
|
||||||
value={formatUptime(status.uptime_seconds)}
|
value={formatUptime(status.uptime_seconds)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-10 text-muted-foreground">
|
<div className="space-y-6">
|
||||||
<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 className="h-4"></div>
|
||||||
|
|
||||||
|
{/* 基础设置 - 监听地址/端口 */}
|
||||||
|
<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} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -319,6 +556,7 @@ 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 (
|
||||||
@@ -344,7 +582,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>
|
||||||
|
|||||||
@@ -1,397 +0,0 @@
|
|||||||
/**
|
|
||||||
* 代理服务设置对话框
|
|
||||||
*/
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,43 +49,39 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("p-1 rounded-xl transition-all", className)}
|
||||||
"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}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
{isPending ? (
|
||||||
) : (
|
<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(
|
||||||
"h-4 w-4 transition-colors",
|
"text-sm font-medium transition-colors select-none",
|
||||||
takeoverEnabled
|
takeoverEnabled
|
||||||
? "text-emerald-500 animate-pulse"
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
: "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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,3 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { ProxyPanel } from "./ProxyPanel";
|
export { ProxyPanel } from "./ProxyPanel";
|
||||||
export { ProxySettingsDialog } from "./ProxySettingsDialog";
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,6 +19,7 @@ import { settingsApi } from "@/lib/api";
|
|||||||
import { useUpdate } from "@/contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { relaunchApp } from "@/lib/updater";
|
import { relaunchApp } from "@/lib/updater";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
interface AboutSectionProps {
|
interface AboutSectionProps {
|
||||||
isPortable: boolean;
|
isPortable: boolean;
|
||||||
@@ -29,6 +32,10 @@ interface ToolVersion {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest
|
||||||
|
npm i -g @openai/codex@latest
|
||||||
|
npm i -g @google/gemini-cli@latest`;
|
||||||
|
|
||||||
export function AboutSection({ isPortable }: AboutSectionProps) {
|
export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||||
// ... (use hooks as before) ...
|
// ... (use hooks as before) ...
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -47,6 +54,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
isChecking,
|
isChecking,
|
||||||
} = useUpdate();
|
} = useUpdate();
|
||||||
|
|
||||||
|
const loadToolVersions = useCallback(async () => {
|
||||||
|
setIsLoadingTools(true);
|
||||||
|
try {
|
||||||
|
const tools = await settingsApi.getToolVersions();
|
||||||
|
setToolVersions(tools);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AboutSection] Failed to load tool versions", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTools(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -150,10 +169,25 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
}
|
}
|
||||||
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
|
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
|
||||||
|
|
||||||
|
const handleCopyInstallCommands = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS);
|
||||||
|
toast.success(t("settings.installCommandsCopied"), { closeButton: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AboutSection] Failed to copy install commands", error);
|
||||||
|
toast.error(t("settings.installCommandsCopyFailed"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const displayVersion = version ?? t("common.unknown");
|
const displayVersion = version ?? t("common.unknown");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<motion.section
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h3 className="text-sm font-medium">{t("common.about")}</h3>
|
<h3 className="text-sm font-medium">{t("common.about")}</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -161,12 +195,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-6 space-y-6">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="gap-1.5 bg-background">
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<h4 className="text-lg font-semibold text-foreground">
|
||||||
|
CC Switch
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="gap-1.5 bg-background/80">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("common.version")}
|
{t("common.version")}
|
||||||
</span>
|
</span>
|
||||||
@@ -185,15 +229,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleOpenReleaseNotes}
|
onClick={handleOpenReleaseNotes}
|
||||||
className="h-9"
|
className="h-8 gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
{t("settings.releaseNotes")}
|
{t("settings.releaseNotes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -201,34 +245,41 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCheckUpdate}
|
onClick={handleCheckUpdate}
|
||||||
disabled={isChecking || isDownloading}
|
disabled={isChecking || isDownloading}
|
||||||
className="min-w-[140px] h-9"
|
className="h-8 gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
{t("settings.updating")}
|
{t("settings.updating")}
|
||||||
</span>
|
</>
|
||||||
) : hasUpdate ? (
|
) : hasUpdate ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
{t("settings.updateTo", {
|
{t("settings.updateTo", {
|
||||||
version: updateInfo?.availableVersion ?? "",
|
version: updateInfo?.availableVersion ?? "",
|
||||||
})}
|
})}
|
||||||
</span>
|
</>
|
||||||
) : isChecking ? (
|
) : isChecking ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<>
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||||
{t("settings.checking")}
|
{t("settings.checking")}
|
||||||
</span>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("settings.checkForUpdates")
|
<>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
{t("settings.checkForUpdates")}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasUpdate && updateInfo && (
|
{hasUpdate && updateInfo && (
|
||||||
<div className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
<p className="font-medium text-primary mb-1">
|
<p className="font-medium text-primary mb-1">
|
||||||
{t("settings.updateAvailable", {
|
{t("settings.updateAvailable", {
|
||||||
version: updateInfo.availableVersion,
|
version: updateInfo.availableVersion,
|
||||||
@@ -239,60 +290,111 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
{updateInfo.notes}
|
{updateInfo.notes}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground px-1">
|
<div className="flex items-center justify-between px-1">
|
||||||
本地环境检查
|
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
|
||||||
</h4>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={loadToolVersions}
|
||||||
|
disabled={isLoadingTools}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={
|
||||||
|
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
{isLoadingTools
|
{["claude", "codex", "gemini"].map((toolName, index) => {
|
||||||
? Array.from({ length: 3 }).map((_, i) => (
|
const tool = toolVersions.find((item) => item.name === toolName);
|
||||||
<div
|
const displayName = tool?.name ?? toolName;
|
||||||
key={i}
|
const title = tool?.version || tool?.error || t("common.unknown");
|
||||||
className="h-20 rounded-xl border border-border bg-card/50 animate-pulse"
|
|
||||||
/>
|
return (
|
||||||
))
|
<motion.div
|
||||||
: toolVersions.map((tool) => (
|
key={toolName}
|
||||||
<div
|
initial={{ opacity: 0, y: 10 }}
|
||||||
key={tool.name}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50"
|
transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}
|
||||||
>
|
whileHover={{ scale: 1.02 }}
|
||||||
<div className="flex items-center justify-between">
|
className="flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium capitalize">
|
<div className="flex items-center gap-2">
|
||||||
{tool.name}
|
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||||
</span>
|
<span className="text-sm font-medium capitalize">
|
||||||
</div>
|
{displayName}
|
||||||
{tool.version ? (
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{tool.latest_version &&
|
|
||||||
tool.version !== tool.latest_version && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
|
|
||||||
Update: {tool.latest_version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
{isLoadingTools ? (
|
||||||
<div
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
className="text-xs font-mono truncate"
|
) : tool?.version ? (
|
||||||
title={tool.version || tool.error || "Unknown"}
|
<div className="flex items-center gap-1.5">
|
||||||
>
|
{tool.latest_version &&
|
||||||
{tool.version ? tool.version : tool.error || "未安装"}
|
tool.version !== tool.latest_version && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
|
||||||
|
{tool.latest_version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div
|
||||||
|
className="text-xs font-mono text-muted-foreground truncate"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{isLoadingTools
|
||||||
|
? t("common.loading")
|
||||||
|
: tool?.version
|
||||||
|
? tool.version
|
||||||
|
: tool?.error || t("common.notInstalled")}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.3 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-medium px-1">
|
||||||
|
{t("settings.oneClickInstall")}
|
||||||
|
</h3>
|
||||||
|
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.oneClickInstallHint")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCopyInstallCommands}
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{t("common.copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
|
||||||
|
{ONE_CLICK_INSTALL_COMMANDS}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="Clear selection"
|
aria-label={t("common.clear")}
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -181,7 +182,6 @@ 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,16 +214,19 @@ export function SettingsPage({
|
|||||||
<TabsTrigger value="advanced">
|
<TabsTrigger value="advanced">
|
||||||
{t("settings.tabAdvanced")}
|
{t("settings.tabAdvanced")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="usage">
|
<TabsTrigger value="usage">{t("usage.title")}</TabsTrigger>
|
||||||
{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 })}
|
||||||
@@ -233,13 +236,18 @@ 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 ? (
|
||||||
<div className="space-y-4">
|
<motion.div
|
||||||
|
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={[]}
|
||||||
@@ -254,10 +262,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">
|
||||||
管理 Claude、Codex 和 Gemini 的配置存储路径
|
{t("settings.advanced.configDir.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,10 +297,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>
|
||||||
@@ -307,7 +315,9 @@ 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}
|
||||||
@@ -330,10 +340,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>
|
||||||
@@ -345,80 +355,118 @@ export function SettingsPage({
|
|||||||
|
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value="failover"
|
value="failover"
|
||||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
>
|
>
|
||||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Activity className="h-5 w-5 text-orange-500" />
|
||||||
<Activity className="h-5 w-5 text-orange-500" />
|
<div className="text-left">
|
||||||
<div className="text-left">
|
<h3 className="text-base font-semibold">
|
||||||
<h3 className="text-base font-semibold">
|
{t("settings.advanced.failover.title")}
|
||||||
自动故障转移
|
</h3>
|
||||||
</h3>
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
{t("settings.advanced.failover.description")}
|
||||||
配置故障转移队列和熔断策略
|
</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">
|
|
||||||
<Switch
|
|
||||||
checked={failoverEnabled}
|
|
||||||
onCheckedChange={setFailoverEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 故障转移队列管理 */}
|
{/* 代理未运行时的提示 */}
|
||||||
<div className="space-y-4">
|
{!isRunning && (
|
||||||
<div>
|
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
<h4 className="text-sm font-semibold">
|
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
{t("proxy.failoverQueue.title", "故障转移队列")}
|
{t("proxy.failover.proxyRequired", {
|
||||||
</h4>
|
defaultValue:
|
||||||
<p className="text-xs text-muted-foreground">
|
"需要先启动代理服务才能配置故障转移",
|
||||||
{t(
|
})}
|
||||||
"proxy.failoverQueue.description",
|
|
||||||
"管理各应用的供应商故障转移顺序",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<Tabs defaultValue="claude" className="w-full">
|
||||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
</TabsList>
|
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||||
<TabsContent value="claude" className="mt-4">
|
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||||
|
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
value="claude"
|
||||||
|
className="mt-4 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="claude"
|
appType="claude"
|
||||||
disabled={!failoverEnabled}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="codex" className="mt-4">
|
<div className="border-t border-border/50 pt-6">
|
||||||
|
<AutoFailoverConfigPanel
|
||||||
|
appType="claude"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="codex"
|
||||||
|
className="mt-4 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="codex"
|
appType="codex"
|
||||||
disabled={!failoverEnabled}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="gemini" className="mt-4">
|
<div className="border-t border-border/50 pt-6">
|
||||||
|
<AutoFailoverConfigPanel
|
||||||
|
appType="codex"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="gemini"
|
||||||
|
className="mt-4 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="gemini"
|
appType="gemini"
|
||||||
disabled={!failoverEnabled}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</div>
|
||||||
</Tabs>
|
<div className="border-t border-border/50 pt-6">
|
||||||
</div>
|
<AutoFailoverConfigPanel
|
||||||
|
appType="gemini"
|
||||||
{/* 熔断器配置 */}
|
disabled={!isRunning}
|
||||||
<div className="border-t border-border/50 pt-6">
|
/>
|
||||||
<AutoFailoverConfigPanel
|
</div>
|
||||||
enabled={failoverEnabled}
|
</TabsContent>
|
||||||
onEnabledChange={setFailoverEnabled}
|
</Tabs>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -432,10 +480,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">
|
||||||
管理各模型 Token 计费规则
|
{t("settings.advanced.pricing.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -454,10 +502,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>
|
||||||
@@ -497,7 +545,7 @@ export function SettingsPage({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -231,10 +231,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
</div>
|
</div>
|
||||||
) : skills.length === 0 ? (
|
) : skills.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<p className="text-lg font-medium text-foreground">
|
||||||
{t("skills.empty")}
|
{t("skills.empty")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{t("skills.emptyDescription")}
|
{t("skills.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -306,10 +306,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
{/* 技能列表或无结果提示 */}
|
{/* 技能列表或无结果提示 */}
|
||||||
{filteredSkills.length === 0 ? (
|
{filteredSkills.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||||
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<p className="text-lg font-medium text-foreground">
|
||||||
{t("skills.noResults")}
|
{t("skills.noResults")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{t("skills.emptyDescription")}
|
{t("skills.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ const Switch = React.forwardRef<
|
|||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-300 dark:data-[state=unchecked]:bg-gray-700",
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-900",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
"pointer-events-none block h-5 w-5 rounded-full bg-white dark:bg-gray-400 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Edit2, Trash2, RefreshCw, Globe } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
|
import type { UniversalProvider } from "@/types";
|
||||||
|
|
||||||
|
interface UniversalProviderCardProps {
|
||||||
|
provider: UniversalProvider;
|
||||||
|
onEdit: (provider: UniversalProvider) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onSync: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UniversalProviderCard({
|
||||||
|
provider,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSync,
|
||||||
|
}: UniversalProviderCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 获取启用的应用列表
|
||||||
|
const enabledApps: string[] = [
|
||||||
|
provider.apps.claude ? "Claude" : null,
|
||||||
|
provider.apps.codex ? "Codex" : null,
|
||||||
|
provider.apps.gemini ? "Gemini" : null,
|
||||||
|
].filter((app): app is string => app !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative rounded-xl border border-border/50 bg-card p-4 transition-all hover:border-border hover:shadow-md">
|
||||||
|
{/* 头部:图标和名称 */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent">
|
||||||
|
<ProviderIcon icon={provider.icon} name={provider.name} size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-foreground">{provider.name}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{provider.providerType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => onSync(provider.id)}
|
||||||
|
title={t("universalProvider.sync", { defaultValue: "同步到应用" })}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => onEdit(provider)}
|
||||||
|
title={t("common.edit", { defaultValue: "编辑" })}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => onDelete(provider.id)}
|
||||||
|
title={t("common.delete", { defaultValue: "删除" })}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 配置信息 */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{/* Base URL */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="truncate text-muted-foreground">
|
||||||
|
{provider.baseUrl || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 启用的应用 */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{enabledApps.map((app) => (
|
||||||
|
<span
|
||||||
|
key={app}
|
||||||
|
className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||||
|
>
|
||||||
|
{app}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{enabledApps.length === 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("universalProvider.noAppsEnabled", {
|
||||||
|
defaultValue: "未启用任何应用",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
{provider.notes && (
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{provider.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||