mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73aa95263c | |||
| e1db104009 | |||
| af9785f566 | |||
| a20fb1afcf | |||
| 760d564817 | |||
| 1121347a45 | |||
| a64e588cf8 | |||
| ab08b906ad | |||
| 26753f3d66 | |||
| d783cbe745 | |||
| d304346514 | |||
| 1efa41082c | |||
| 72412e7abd | |||
| b496e250dd | |||
| 087622bf6d | |||
| 1e95a3f16b | |||
| bfcdde5011 | |||
| 39964b5733 | |||
| 2298119177 | |||
| d356f1e208 | |||
| 434ffd559d | |||
| 38e8949152 | |||
| 96ec6c561e | |||
| 6423e2e8ab | |||
| 05973bf959 | |||
| c0122d717c | |||
| d12347d837 | |||
| fef4c239c4 | |||
| a35d112cd4 | |||
| 665d34609d | |||
| 0eec47fd64 | |||
| e9cb7098a5 | |||
| 247973b37e | |||
| 94b83bfa3a | |||
| c7ef4365b8 | |||
| 4c94e70f97 |
@@ -3,6 +3,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/trending/typescript)
|
||||||
[](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)
|
||||||
@@ -11,13 +12,21 @@
|
|||||||
|
|
||||||
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
|
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
|
**From Provider Switcher to All-in-One AI CLI Management Platform**
|
||||||
|
|
||||||
|
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ❤️Sponsor
|
## ❤️Sponsor
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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)!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,12 +41,6 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
<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 ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
|
||||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you’ll receive an extra 10% bonus credit on your first top-up!
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|||||||
+10
-8
@@ -3,6 +3,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/trending/typescript)
|
||||||
[](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)
|
||||||
@@ -11,13 +12,21 @@
|
|||||||
|
|
||||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
|
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md) | [v3.8.0 リリースノート](docs/release-note-v3.8.0-en.md)
|
||||||
|
|
||||||
|
**プロバイダスイッチャーから AI CLI 一体型管理プラットフォームへ**
|
||||||
|
|
||||||
|
Claude Code・Codex・Gemini CLI のプロバイダ設定、MCP サーバー、Skills 拡張、システムプロンプトを統合管理。
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ❤️スポンサー
|
## ❤️スポンサー
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
本プロジェクトは 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% オフになります!
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,13 +41,6 @@
|
|||||||
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
|
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
|
||||||
</tr>
|
</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コーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
|
|
||||||
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## スクリーンショット
|
## スクリーンショット
|
||||||
|
|||||||
+10
-6
@@ -3,6 +3,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/trending/typescript)
|
||||||
[](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)
|
||||||
@@ -11,13 +12,21 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md) | [v3.8.0 发布说明](docs/release-note-v3.8.0-zh.md)
|
||||||
|
|
||||||
|
**从供应商切换器到 AI CLI 一体化管理平台**
|
||||||
|
|
||||||
|
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## ❤️赞助商
|
## ❤️赞助商
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
感谢智谱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)购买可以享受九折优惠。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,11 +41,6 @@
|
|||||||
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
|
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
|
||||||
</tr>
|
</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>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -50,7 +50,6 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@lobehub/icons-static-svg": "^1.73.0",
|
"@lobehub/icons-static-svg": "^1.73.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -68,9 +67,7 @@
|
|||||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"framer-motion": "^12.23.25",
|
|
||||||
"i18next": "^25.5.2",
|
"i18next": "^25.5.2",
|
||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
|
|||||||
Generated
-121
@@ -44,9 +44,6 @@ importers:
|
|||||||
'@lobehub/icons-static-svg':
|
'@lobehub/icons-static-svg':
|
||||||
specifier: ^1.73.0
|
specifier: ^1.73.0
|
||||||
version: 1.73.0
|
version: 1.73.0
|
||||||
'@radix-ui/react-accordion':
|
|
||||||
specifier: ^1.2.12
|
|
||||||
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@radix-ui/react-checkbox':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -98,15 +95,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
cmdk:
|
|
||||||
specifier: ^1.1.1
|
|
||||||
version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
codemirror:
|
codemirror:
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
framer-motion:
|
|
||||||
specifier: ^12.23.25
|
|
||||||
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^25.5.2
|
specifier: ^25.5.2
|
||||||
version: 25.5.2(typescript@5.9.2)
|
version: 25.5.2(typescript@5.9.2)
|
||||||
@@ -661,19 +652,6 @@ packages:
|
|||||||
'@radix-ui/primitive@1.1.3':
|
'@radix-ui/primitive@1.1.3':
|
||||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
|
|
||||||
'@radix-ui/react-accordion@1.2.12':
|
|
||||||
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/react': '*'
|
|
||||||
'@types/react-dom': '*'
|
|
||||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
|
||||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@types/react':
|
|
||||||
optional: true
|
|
||||||
'@types/react-dom':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7':
|
'@radix-ui/react-arrow@1.1.7':
|
||||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -700,19 +678,6 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-collapsible@1.1.12':
|
|
||||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
|
||||||
peerDependencies:
|
|
||||||
'@types/react': '*'
|
|
||||||
'@types/react-dom': '*'
|
|
||||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
|
||||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@types/react':
|
|
||||||
optional: true
|
|
||||||
'@types/react-dom':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7':
|
'@radix-ui/react-collection@1.1.7':
|
||||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1562,12 +1527,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
cmdk@1.1.1:
|
|
||||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
|
||||||
peerDependencies:
|
|
||||||
react: ^18 || ^19 || ^19.0.0-rc
|
|
||||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
|
||||||
|
|
||||||
codemirror@6.0.2:
|
codemirror@6.0.2:
|
||||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||||
|
|
||||||
@@ -1793,20 +1752,6 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
framer-motion@12.23.25:
|
|
||||||
resolution: {integrity: sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==}
|
|
||||||
peerDependencies:
|
|
||||||
'@emotion/is-prop-valid': '*'
|
|
||||||
react: ^18.0.0 || ^19.0.0
|
|
||||||
react-dom: ^18.0.0 || ^19.0.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@emotion/is-prop-valid':
|
|
||||||
optional: true
|
|
||||||
react:
|
|
||||||
optional: true
|
|
||||||
react-dom:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -2090,12 +2035,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
motion-dom@12.23.23:
|
|
||||||
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
|
|
||||||
|
|
||||||
motion-utils@12.23.6:
|
|
||||||
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
|
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -3244,23 +3183,6 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/primitive@1.1.3': {}
|
'@radix-ui/primitive@1.1.3': {}
|
||||||
|
|
||||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
|
||||||
dependencies:
|
|
||||||
'@radix-ui/primitive': 1.1.3
|
|
||||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
|
||||||
optionalDependencies:
|
|
||||||
'@types/react': 18.3.23
|
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
|
||||||
|
|
||||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -3286,22 +3208,6 @@ snapshots:
|
|||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
||||||
|
|
||||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
|
||||||
dependencies:
|
|
||||||
'@radix-ui/primitive': 1.1.3
|
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
|
||||||
optionalDependencies:
|
|
||||||
'@types/react': 18.3.23
|
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.23)
|
|
||||||
|
|
||||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
||||||
@@ -4082,18 +3988,6 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
|
||||||
dependencies:
|
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
|
|
||||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@types/react'
|
|
||||||
- '@types/react-dom'
|
|
||||||
|
|
||||||
codemirror@6.0.2:
|
codemirror@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.18.7
|
'@codemirror/autocomplete': 6.18.7
|
||||||
@@ -4308,15 +4202,6 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
framer-motion@12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
|
||||||
dependencies:
|
|
||||||
motion-dom: 12.23.23
|
|
||||||
motion-utils: 12.23.6
|
|
||||||
tslib: 2.8.1
|
|
||||||
optionalDependencies:
|
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4559,12 +4444,6 @@ snapshots:
|
|||||||
|
|
||||||
min-indent@1.0.1: {}
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
motion-dom@12.23.23:
|
|
||||||
dependencies:
|
|
||||||
motion-utils: 12.23.6
|
|
||||||
|
|
||||||
motion-utils@12.23.6: {}
|
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2):
|
msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2):
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
//! 故障转移队列命令
|
|
||||||
//!
|
|
||||||
//! 管理代理模式下的故障转移队列
|
|
||||||
|
|
||||||
use crate::database::FailoverQueueItem;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::store::AppState;
|
|
||||||
|
|
||||||
/// 获取故障转移队列
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<Vec<FailoverQueueItem>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_failover_queue(&app_type)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_available_providers_for_failover(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<Vec<Provider>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_available_providers_for_failover(&app_type)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加供应商到故障转移队列
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn add_to_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.add_to_failover_queue(&app_type, &provider_id)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从故障转移队列移除供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn remove_from_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.remove_from_failover_queue(&app_type, &provider_id)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新排序故障转移队列
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn reorder_failover_queue(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_ids: Vec<String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.reorder_failover_queue(&app_type, &provider_ids)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置故障转移队列项的启用状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_failover_item_enabled(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_failover_item_enabled(&app_type, &provider_id, enabled)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
@@ -58,194 +58,3 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
|||||||
pub async fn get_migration_result() -> Result<bool, String> {
|
pub async fn get_migration_result() -> Result<bool, String> {
|
||||||
Ok(crate::init_status::take_migration_success())
|
Ok(crate::init_status::take_migration_success())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct ToolVersion {
|
|
||||||
name: String,
|
|
||||||
version: Option<String>,
|
|
||||||
latest_version: Option<String>, // 新增字段:最新版本
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
|
||||||
let tools = vec!["claude", "codex", "gemini"];
|
|
||||||
let mut results = Vec::new();
|
|
||||||
|
|
||||||
// 用于获取远程版本的 client
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.user_agent("cc-switch/1.0")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
for tool in tools {
|
|
||||||
// 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径
|
|
||||||
let (local_version, local_error) = {
|
|
||||||
// 先尝试直接执行
|
|
||||||
let direct_result = try_get_version(tool);
|
|
||||||
|
|
||||||
if direct_result.0.is_some() {
|
|
||||||
direct_result
|
|
||||||
} else {
|
|
||||||
// 扫描常见的 npm 全局安装路径
|
|
||||||
scan_cli_version(tool)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. 获取远程最新版本
|
|
||||||
let latest_version = match tool {
|
|
||||||
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
|
|
||||||
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
|
|
||||||
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
results.push(ToolVersion {
|
|
||||||
name: tool.to_string(),
|
|
||||||
version: local_version,
|
|
||||||
latest_version,
|
|
||||||
error: local_error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper function to fetch latest version from npm registry
|
|
||||||
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
|
|
||||||
let url = format!("https://registry.npmjs.org/{package}");
|
|
||||||
match client.get(&url).send().await {
|
|
||||||
Ok(resp) => {
|
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
|
||||||
json.get("dist-tags")
|
|
||||||
.and_then(|tags| tags.get("latest"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从版本输出中提取纯版本号
|
|
||||||
fn extract_version(raw: &str) -> String {
|
|
||||||
// 匹配 semver 格式: x.y.z 或 x.y.z-xxx
|
|
||||||
let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").unwrap();
|
|
||||||
re.find(raw)
|
|
||||||
.map(|m| m.as_str().to_string())
|
|
||||||
.unwrap_or_else(|| raw.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 尝试直接执行命令获取版本
|
|
||||||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let output = if cfg!(target_os = "windows") {
|
|
||||||
Command::new("cmd")
|
|
||||||
.args(["/C", &format!("{tool} --version")])
|
|
||||||
.output()
|
|
||||||
} else {
|
|
||||||
Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(format!("{tool} --version"))
|
|
||||||
.output()
|
|
||||||
};
|
|
||||||
|
|
||||||
match output {
|
|
||||||
Ok(out) => {
|
|
||||||
if out.status.success() {
|
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
(Some(extract_version(&raw)), None)
|
|
||||||
} else {
|
|
||||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
Some(if err.is_empty() {
|
|
||||||
"未安装或无法执行".to_string()
|
|
||||||
} else {
|
|
||||||
err
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => (None, Some(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描常见路径查找 CLI
|
|
||||||
fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let home = dirs::home_dir().unwrap_or_default();
|
|
||||||
|
|
||||||
// 常见的 npm 全局安装路径
|
|
||||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
|
||||||
home.join(".npm-global/bin"),
|
|
||||||
home.join(".local/bin"),
|
|
||||||
home.join("n/bin"), // n version manager
|
|
||||||
];
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
search_paths.push(std::path::PathBuf::from("/opt/homebrew/bin"));
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/local/bin"));
|
|
||||||
search_paths.push(std::path::PathBuf::from("/usr/bin"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
if let Some(appdata) = dirs::data_dir() {
|
|
||||||
search_paths.push(appdata.join("npm"));
|
|
||||||
}
|
|
||||||
search_paths.push(std::path::PathBuf::from("C:\\Program Files\\nodejs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扫描 nvm 目录下的所有 node 版本
|
|
||||||
let nvm_base = home.join(".nvm/versions/node");
|
|
||||||
if nvm_base.exists() {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&nvm_base) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let bin_path = entry.path().join("bin");
|
|
||||||
if bin_path.exists() {
|
|
||||||
search_paths.push(bin_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在每个路径中查找工具
|
|
||||||
for path in &search_paths {
|
|
||||||
let tool_path = if cfg!(target_os = "windows") {
|
|
||||||
path.join(format!("{tool}.cmd"))
|
|
||||||
} else {
|
|
||||||
path.join(tool)
|
|
||||||
};
|
|
||||||
|
|
||||||
if tool_path.exists() {
|
|
||||||
// 构建 PATH 环境变量,确保 node 可被找到
|
|
||||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
|
||||||
let new_path = format!("{}:{}", path.display(), current_path);
|
|
||||||
|
|
||||||
let output = Command::new(&tool_path)
|
|
||||||
.arg("--version")
|
|
||||||
.env("PATH", &new_path)
|
|
||||||
.output();
|
|
||||||
|
|
||||||
if let Ok(out) = output {
|
|
||||||
if out.status.success() {
|
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
||||||
return (Some(extract_version(&raw)), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(None, Some("未安装或无法执行".to_string()))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,31 +3,29 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod deeplink;
|
mod deeplink;
|
||||||
mod env;
|
mod env;
|
||||||
mod failover;
|
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod misc;
|
mod misc;
|
||||||
|
mod model_test;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod settings;
|
mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
mod stream_check;
|
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use config::*;
|
pub use config::*;
|
||||||
pub use deeplink::*;
|
pub use deeplink::*;
|
||||||
pub use env::*;
|
pub use env::*;
|
||||||
pub use failover::*;
|
|
||||||
pub use import_export::*;
|
pub use import_export::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
|
pub use model_test::*;
|
||||||
pub use plugin::*;
|
pub use plugin::*;
|
||||||
pub use prompt::*;
|
pub use prompt::*;
|
||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use proxy::*;
|
pub use proxy::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use skill::*;
|
pub use skill::*;
|
||||||
pub use stream_check::*;
|
|
||||||
pub use usage::*;
|
pub use usage::*;
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! 模型测试相关命令
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::model_test::{
|
||||||
|
ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService,
|
||||||
|
};
|
||||||
|
use crate::store::AppState;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// 测试单个供应商的模型可用性
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_provider_model(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: AppType,
|
||||||
|
provider_id: String,
|
||||||
|
) -> Result<ModelTestResult, AppError> {
|
||||||
|
// 获取测试配置
|
||||||
|
let config = state.db.get_model_test_config()?;
|
||||||
|
|
||||||
|
// 获取供应商
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
let provider = providers
|
||||||
|
.get(&provider_id)
|
||||||
|
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||||
|
|
||||||
|
// 执行测试
|
||||||
|
let result = ModelTestService::test_provider(&app_type, provider, &config).await?;
|
||||||
|
|
||||||
|
// 记录日志
|
||||||
|
let _ = state.db.save_model_test_log(
|
||||||
|
&provider_id,
|
||||||
|
&provider.name,
|
||||||
|
app_type.as_str(),
|
||||||
|
&result.model_used,
|
||||||
|
&config.test_prompt,
|
||||||
|
&result,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量测试所有供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_all_providers_model(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: AppType,
|
||||||
|
proxy_targets_only: bool,
|
||||||
|
) -> Result<Vec<(String, ModelTestResult)>, AppError> {
|
||||||
|
let config = state.db.get_model_test_config()?;
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for (id, provider) in providers {
|
||||||
|
// 如果只测试代理目标,跳过非代理目标
|
||||||
|
if proxy_targets_only && !provider.is_proxy_target.unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ModelTestService::test_provider(&app_type, &provider, &config).await {
|
||||||
|
Ok(result) => {
|
||||||
|
// 记录日志
|
||||||
|
let _ = state.db.save_model_test_log(
|
||||||
|
&id,
|
||||||
|
&provider.name,
|
||||||
|
app_type.as_str(),
|
||||||
|
&result.model_used,
|
||||||
|
&config.test_prompt,
|
||||||
|
&result,
|
||||||
|
);
|
||||||
|
results.push((id, result));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_result = ModelTestResult {
|
||||||
|
success: false,
|
||||||
|
message: e.to_string(),
|
||||||
|
response_time_ms: None,
|
||||||
|
http_status: None,
|
||||||
|
model_used: String::new(),
|
||||||
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
|
};
|
||||||
|
results.push((id, error_result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型测试配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_model_test_config(state: State<'_, AppState>) -> Result<ModelTestConfig, AppError> {
|
||||||
|
state.db.get_model_test_config()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存模型测试配置
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_model_test_config(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: ModelTestConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
state.db.save_model_test_config(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型测试日志
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_model_test_logs(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_type: Option<String>,
|
||||||
|
provider_id: Option<String>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
) -> Result<Vec<ModelTestLog>, AppError> {
|
||||||
|
state.db.get_model_test_logs(
|
||||||
|
app_type.as_deref(),
|
||||||
|
provider_id.as_deref(),
|
||||||
|
limit.unwrap_or(50),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理旧的测试日志
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cleanup_model_test_logs(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
keep_count: Option<u32>,
|
||||||
|
) -> Result<u64, AppError> {
|
||||||
|
state.db.cleanup_model_test_logs(keep_count.unwrap_or(100))
|
||||||
|
}
|
||||||
@@ -86,6 +86,19 @@ pub fn switch_provider(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置代理目标供应商
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_proxy_target_provider(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: String,
|
||||||
|
id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
|
ProviderService::set_proxy_target(state.inner(), app_type, &id)
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||||
ProviderService::import_default_config(state, app_type)
|
ProviderService::import_default_config(state, app_type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,20 @@
|
|||||||
//! 提供前端调用的 API 接口
|
//! 提供前端调用的 API 接口
|
||||||
|
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
|
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
/// 启动代理服务器(带 Live 配置接管)
|
/// 启动代理服务器
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_proxy_with_takeover(
|
pub async fn start_proxy_server(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
) -> Result<ProxyServerInfo, String> {
|
) -> Result<ProxyServerInfo, String> {
|
||||||
state.proxy_service.start_with_takeover().await
|
state.proxy_service.start().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止代理服务器(恢复 Live 配置)
|
/// 停止代理服务器
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||||
state.proxy_service.stop_with_restore().await
|
state.proxy_service.stop().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取代理服务器状态
|
/// 获取代理服务器状态
|
||||||
@@ -46,105 +45,3 @@ pub async fn update_proxy_config(
|
|||||||
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> {
|
||||||
Ok(state.proxy_service.is_running().await)
|
Ok(state.proxy_service.is_running().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否处于 Live 接管模式
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn is_live_takeover_active(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
|
||||||
state.proxy_service.is_takeover_active().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 代理模式下切换供应商(热切换)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn switch_proxy_provider(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
app_type: String,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.switch_proxy_target(&app_type, &provider_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 故障转移相关命令 ====================
|
|
||||||
|
|
||||||
/// 获取供应商健康状态
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_provider_health(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<ProviderHealth, String> {
|
|
||||||
let db = &state.db;
|
|
||||||
db.get_provider_health(&provider_id, &app_type)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置熔断器
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn reset_circuit_breaker(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// 1. 重置数据库健康状态
|
|
||||||
let db = &state.db;
|
|
||||||
db.update_provider_health(&provider_id, &app_type, true, None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 2. 如果代理正在运行,重置内存中的熔断器状态
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取熔断器配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_circuit_breaker_config(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<CircuitBreakerConfig, String> {
|
|
||||||
let db = &state.db;
|
|
||||||
db.get_circuit_breaker_config()
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新熔断器配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn update_circuit_breaker_config(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
config: CircuitBreakerConfig,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let db = &state.db;
|
|
||||||
|
|
||||||
// 1. 更新数据库配置
|
|
||||||
db.update_circuit_breaker_config(&config)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 2. 如果代理正在运行,热更新内存中的熔断器配置
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.update_circuit_breaker_configs(config)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取熔断器统计信息(仅当代理服务器运行时)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_circuit_breaker_stats(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
) -> Result<Option<CircuitBreakerStats>, String> {
|
|
||||||
// 这个功能需要访问运行中的代理服务器的内存状态
|
|
||||||
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
|
|
||||||
let _ = (state, provider_id, app_type);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::format_skill_error;
|
use crate::error::format_skill_error;
|
||||||
use crate::services::skill::SkillState;
|
use crate::services::skill::SkillState;
|
||||||
use crate::services::{Skill, SkillRepo, SkillService};
|
use crate::services::{Skill, SkillRepo, SkillService};
|
||||||
@@ -9,46 +8,15 @@ use tauri::State;
|
|||||||
|
|
||||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||||
|
|
||||||
/// 解析 app 参数为 AppType
|
|
||||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
|
||||||
match app.to_lowercase().as_str() {
|
|
||||||
"claude" => Ok(AppType::Claude),
|
|
||||||
"codex" => Ok(AppType::Codex),
|
|
||||||
"gemini" => Ok(AppType::Gemini),
|
|
||||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据 app_type 生成带前缀的 skill key
|
|
||||||
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
|
||||||
let prefix = match app_type {
|
|
||||||
AppType::Claude => "claude",
|
|
||||||
AppType::Codex => "codex",
|
|
||||||
AppType::Gemini => "gemini",
|
|
||||||
};
|
|
||||||
format!("{prefix}:{directory}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_skills(
|
pub async fn get_skills(
|
||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<Vec<Skill>, String> {
|
) -> Result<Vec<Skill>, String> {
|
||||||
get_skills_for_app("claude".to_string(), service, app_state).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_skills_for_app(
|
|
||||||
app: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<Vec<Skill>, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let skills = service
|
let skills = service
|
||||||
|
.0
|
||||||
.list_skills(repos)
|
.list_skills(repos)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -58,19 +26,16 @@ pub async fn get_skills_for_app(
|
|||||||
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
let existing_states = app_state.db.get_skills().unwrap_or_default();
|
||||||
|
|
||||||
for skill in &skills {
|
for skill in &skills {
|
||||||
if skill.installed {
|
if skill.installed && !existing_states.contains_key(&skill.directory) {
|
||||||
let key = get_skill_key(&app_type, &skill.directory);
|
// 本地有该 skill,但数据库中没有记录,自动添加
|
||||||
if !existing_states.contains_key(&key) {
|
if let Err(e) = app_state.db.update_skill_state(
|
||||||
// 本地有该 skill,但数据库中没有记录,自动添加
|
&skill.directory,
|
||||||
if let Err(e) = app_state.db.update_skill_state(
|
&SkillState {
|
||||||
&key,
|
installed: true,
|
||||||
&SkillState {
|
installed_at: Utc::now(),
|
||||||
installed: true,
|
},
|
||||||
installed_at: Utc::now(),
|
) {
|
||||||
},
|
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
|
||||||
) {
|
|
||||||
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,23 +49,11 @@ pub async fn install_skill(
|
|||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
install_skill_for_app("claude".to_string(), directory, service, app_state).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_skill_for_app(
|
|
||||||
app: String,
|
|
||||||
directory: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 先在不持有写锁的情况下收集仓库与技能信息
|
// 先在不持有写锁的情况下收集仓库与技能信息
|
||||||
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let skills = service
|
let skills = service
|
||||||
|
.0
|
||||||
.list_skills(repos)
|
.list_skills(repos)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -140,16 +93,16 @@ pub async fn install_skill_for_app(
|
|||||||
};
|
};
|
||||||
|
|
||||||
service
|
service
|
||||||
|
.0
|
||||||
.install_skill(directory.clone(), repo)
|
.install_skill(directory.clone(), repo)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = get_skill_key(&app_type, &directory);
|
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.update_skill_state(
|
.update_skill_state(
|
||||||
&key,
|
&directory,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: true,
|
installed: true,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
@@ -166,29 +119,16 @@ pub fn uninstall_skill(
|
|||||||
service: State<'_, SkillServiceState>,
|
service: State<'_, SkillServiceState>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn uninstall_skill_for_app(
|
|
||||||
app: String,
|
|
||||||
directory: String,
|
|
||||||
_service: State<'_, SkillServiceState>,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let app_type = parse_app_type(&app)?;
|
|
||||||
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
service
|
service
|
||||||
|
.0
|
||||||
.uninstall_skill(directory.clone())
|
.uninstall_skill(directory.clone())
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Remove from database by setting installed = false
|
// Remove from database by setting installed = false
|
||||||
let key = get_skill_key(&app_type, &directory);
|
|
||||||
app_state
|
app_state
|
||||||
.db
|
.db
|
||||||
.update_skill_state(
|
.update_skill_state(
|
||||||
&key,
|
&directory,
|
||||||
&SkillState {
|
&SkillState {
|
||||||
installed: false,
|
installed: false,
|
||||||
installed_at: Utc::now(),
|
installed_at: Utc::now(),
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
//! 流式健康检查命令
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::stream_check::{
|
|
||||||
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
|
|
||||||
};
|
|
||||||
use crate::store::AppState;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
/// 流式健康检查(单个供应商)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn stream_check_provider(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_type: AppType,
|
|
||||||
provider_id: String,
|
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
|
||||||
let config = state.db.get_stream_check_config()?;
|
|
||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
|
||||||
let provider = providers
|
|
||||||
.get(&provider_id)
|
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
|
||||||
|
|
||||||
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
|
|
||||||
|
|
||||||
// 记录日志
|
|
||||||
let _ =
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.save_stream_check_log(&provider_id, &provider.name, app_type.as_str(), &result);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 批量流式健康检查
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn stream_check_all_providers(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
app_type: AppType,
|
|
||||||
proxy_targets_only: bool,
|
|
||||||
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
|
|
||||||
let config = state.db.get_stream_check_config()?;
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
|
||||||
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
|
|
||||||
let mut ids = HashSet::new();
|
|
||||||
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
|
||||||
ids.insert(current_id);
|
|
||||||
}
|
|
||||||
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
|
||||||
for item in queue {
|
|
||||||
if item.enabled {
|
|
||||||
ids.insert(item.provider_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(ids)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
for (id, provider) in providers {
|
|
||||||
if let Some(ids) = &allowed_ids {
|
|
||||||
if !ids.contains(&id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| StreamCheckResult {
|
|
||||||
status: HealthStatus::Failed,
|
|
||||||
success: false,
|
|
||||||
message: e.to_string(),
|
|
||||||
response_time_ms: None,
|
|
||||||
http_status: None,
|
|
||||||
model_used: String::new(),
|
|
||||||
tested_at: chrono::Utc::now().timestamp(),
|
|
||||||
retry_count: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = state
|
|
||||||
.db
|
|
||||||
.save_stream_check_log(&id, &provider.name, app_type.as_str(), &result);
|
|
||||||
|
|
||||||
results.push((id, result));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取流式检查配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
|
|
||||||
state.db.get_stream_check_config()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存流式检查配置
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn save_stream_check_config(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
config: StreamCheckConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
state.db.save_stream_check_config(&config)
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
//! 故障转移队列 DAO
|
|
||||||
//!
|
|
||||||
//! 管理代理模式下的故障转移队列
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// 故障转移队列条目
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct FailoverQueueItem {
|
|
||||||
pub provider_id: String,
|
|
||||||
pub provider_name: String,
|
|
||||||
pub queue_order: i32,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取故障转移队列(按 queue_order 排序)
|
|
||||||
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
|
|
||||||
FROM failover_queue fq
|
|
||||||
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
|
|
||||||
WHERE fq.app_type = ?1
|
|
||||||
ORDER BY fq.queue_order ASC",
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let items = stmt
|
|
||||||
.query_map([app_type], |row| {
|
|
||||||
Ok(FailoverQueueItem {
|
|
||||||
provider_id: row.get(0)?,
|
|
||||||
provider_name: row.get(1)?,
|
|
||||||
queue_order: row.get(2)?,
|
|
||||||
enabled: row.get(3)?,
|
|
||||||
created_at: row.get(4)?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
|
||||||
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 mut result = Vec::new();
|
|
||||||
for item in queue {
|
|
||||||
if item.enabled {
|
|
||||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
|
||||||
result.push(provider.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加供应商到故障转移队列末尾
|
|
||||||
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
|
||||||
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(
|
|
||||||
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
|
||||||
rusqlite::params![app_type, provider_id, max_order + 1, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从故障转移队列中移除供应商
|
|
||||||
pub fn remove_from_failover_queue(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 获取被删除项的 queue_order
|
|
||||||
let removed_order: Option<i32> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
[app_type, provider_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// 删除该项
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
[app_type, provider_id],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 重新排序后面的项(填补空隙)
|
|
||||||
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(
|
|
||||||
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
rusqlite::params![app_type, provider_id, enabled],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空故障转移队列
|
|
||||||
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查供应商是否在故障转移队列中
|
|
||||||
pub fn is_in_failover_queue(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let count: i32 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
[app_type, provider_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
|
||||||
pub fn get_available_providers_for_failover(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<Vec<Provider>, AppError> {
|
|
||||||
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
|
|
||||||
.into_values()
|
|
||||||
.filter(|p| !queue_ids.contains(&p.id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(available)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Database access operations for each domain
|
//! Database access operations for each domain
|
||||||
|
|
||||||
pub mod failover;
|
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod stream_check;
|
|
||||||
|
|
||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
// 导出 FailoverQueueItem 供外部使用
|
|
||||||
pub use failover::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, is_proxy_target
|
||||||
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 is_proxy_target: 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,
|
||||||
|
is_proxy_target: Some(is_proxy_target),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
@@ -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, is_proxy_target
|
||||||
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 is_proxy_target: 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,
|
||||||
|
is_proxy_target: Some(is_proxy_target),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -170,6 +174,26 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取代理目标供应商 ID
|
||||||
|
pub fn get_proxy_target_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_proxy_target = 1 LIMIT 1")
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut rows = stmt
|
||||||
|
.query(params![app_type])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||||
|
Ok(Some(
|
||||||
|
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 保存供应商(新增或更新)
|
/// 保存供应商(新增或更新)
|
||||||
///
|
///
|
||||||
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
|
||||||
@@ -184,17 +208,17 @@ impl Database {
|
|||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 is_proxy_target)
|
||||||
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, is_proxy_target 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, is_proxy_target) = existing.unwrap_or((false, false));
|
||||||
|
|
||||||
if is_update {
|
if is_update {
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||||
@@ -210,8 +234,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",
|
is_proxy_target = ?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 +249,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,
|
||||||
|
is_proxy_target,
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
],
|
],
|
||||||
@@ -234,8 +260,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, is_proxy_target
|
||||||
) 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 +276,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,
|
||||||
|
is_proxy_target,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -305,26 +332,47 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新供应商的 settings_config(仅更新配置,不改变其他字段)
|
/// 设置代理目标供应商
|
||||||
pub fn update_provider_settings_config(
|
pub fn set_proxy_target_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
||||||
&self,
|
let mut conn = lock_conn!(self.conn);
|
||||||
app_type: &str,
|
let tx = conn
|
||||||
provider_id: &str,
|
.transaction()
|
||||||
settings_config: &serde_json::Value,
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
// 重置所有为 0
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3",
|
"UPDATE providers SET is_proxy_target = 0 WHERE app_type = ?1",
|
||||||
params![
|
params![app_type],
|
||||||
serde_json::to_string(settings_config).unwrap(),
|
|
||||||
provider_id,
|
|
||||||
app_type
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 设置新的代理目标供应商
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE providers SET is_proxy_target = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
|
params![id, app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取所有活跃的代理目标
|
||||||
|
pub fn get_all_proxy_targets(&self) -> Result<Vec<(String, String, String)>, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT app_type, name, id FROM providers WHERE is_proxy_target = 1")
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let targets = stmt
|
||||||
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(targets)
|
||||||
|
}
|
||||||
|
|
||||||
/// 添加自定义端点
|
/// 添加自定义端点
|
||||||
pub fn add_custom_endpoint(
|
pub fn add_custom_endpoint(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ impl Database {
|
|||||||
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 enabled, listen_address, listen_port, max_retries,
|
||||||
request_timeout, enable_logging, live_takeover_active
|
request_timeout, enable_logging
|
||||||
FROM proxy_config WHERE id = 1",
|
FROM proxy_config WHERE id = 1",
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -28,7 +28,6 @@ impl Database {
|
|||||||
max_retries: row.get::<_, i32>(3)? as u8,
|
max_retries: row.get::<_, i32>(3)? as u8,
|
||||||
request_timeout: row.get::<_, i32>(4)? as u64,
|
request_timeout: row.get::<_, i32>(4)? as u64,
|
||||||
enable_logging: row.get::<_, i32>(5)? != 0,
|
enable_logging: row.get::<_, i32>(5)? != 0,
|
||||||
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -52,8 +51,8 @@ impl Database {
|
|||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO proxy_config
|
"INSERT OR REPLACE INTO proxy_config
|
||||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
|
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at)
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7,
|
||||||
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
||||||
datetime('now'))",
|
datetime('now'))",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
@@ -63,7 +62,6 @@ impl Database {
|
|||||||
config.max_retries as i32,
|
config.max_retries as i32,
|
||||||
config.request_timeout 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", // 兼容旧字段,写入默认值
|
"claude", // 兼容旧字段,写入默认值
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -72,30 +70,6 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 Live 接管状态
|
|
||||||
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
|
|
||||||
rusqlite::params![if active { 1 } else { 0 }],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否处于 Live 接管模式
|
|
||||||
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let active: i32 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(0);
|
|
||||||
Ok(active != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Provider Health ====================
|
// ==================== Provider Health ====================
|
||||||
|
|
||||||
/// 获取Provider健康状态
|
/// 获取Provider健康状态
|
||||||
@@ -129,31 +103,12 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 更新Provider健康状态
|
/// 更新Provider健康状态
|
||||||
///
|
|
||||||
/// 使用默认阈值(5)判断是否健康,建议使用 `update_provider_health_with_threshold` 传入配置的阈值
|
|
||||||
pub async fn update_provider_health(
|
pub async fn update_provider_health(
|
||||||
&self,
|
&self,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
success: bool,
|
success: bool,
|
||||||
error_msg: Option<String>,
|
error_msg: Option<String>,
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// 默认阈值与 CircuitBreakerConfig::default() 保持一致
|
|
||||||
self.update_provider_health_with_threshold(provider_id, app_type, success, error_msg, 5)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新Provider健康状态(带阈值参数)
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `failure_threshold` - 连续失败多少次后标记为不健康
|
|
||||||
pub async fn update_provider_health_with_threshold(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
success: bool,
|
|
||||||
error_msg: Option<String>,
|
|
||||||
failure_threshold: u32,
|
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
@@ -161,7 +116,7 @@ impl Database {
|
|||||||
|
|
||||||
// 先查询当前状态
|
// 先查询当前状态
|
||||||
let current = conn.query_row(
|
let current = conn.query_row(
|
||||||
"SELECT consecutive_failures FROM provider_health
|
"SELECT consecutive_failures 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| Ok(row.get::<_, i64>(0)? as u32),
|
|row| Ok(row.get::<_, i64>(0)? as u32),
|
||||||
@@ -173,8 +128,7 @@ impl Database {
|
|||||||
} else {
|
} else {
|
||||||
// 失败:增加失败计数
|
// 失败:增加失败计数
|
||||||
let failures = current.unwrap_or(0) + 1;
|
let failures = current.unwrap_or(0) + 1;
|
||||||
// 使用传入的阈值而非硬编码
|
let healthy = if failures >= 3 { 0 } else { 1 };
|
||||||
let healthy = if failures >= failure_threshold { 0 } else { 1 };
|
|
||||||
(healthy, failures)
|
(healthy, failures)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,10 +143,10 @@ impl Database {
|
|||||||
"INSERT OR REPLACE INTO provider_health
|
"INSERT OR REPLACE INTO provider_health
|
||||||
(provider_id, app_type, is_healthy, consecutive_failures,
|
(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)
|
||||||
VALUES (?1, ?2, ?3, ?4,
|
VALUES (?1, ?2, ?3, ?4,
|
||||||
COALESCE(?5, (SELECT last_success_at FROM provider_health
|
COALESCE(?5, (SELECT last_success_at FROM provider_health
|
||||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||||
COALESCE(?6, (SELECT last_failure_at FROM provider_health
|
COALESCE(?6, (SELECT last_failure_at FROM provider_health
|
||||||
WHERE provider_id = ?1 AND app_type = ?2)),
|
WHERE provider_id = ?1 AND app_type = ?2)),
|
||||||
?7, ?8)",
|
?7, ?8)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
@@ -211,87 +165,28 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重置Provider健康状态
|
// ==================== Proxy Usage (可选) ====================
|
||||||
pub async fn reset_provider_health(
|
|
||||||
&self,
|
/// 记录代理使用统计
|
||||||
provider_id: &str,
|
#[allow(dead_code)]
|
||||||
app_type: &str,
|
pub async fn record_proxy_usage(&self, record: &ProxyUsageRecord) -> Result<(), AppError> {
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
"INSERT INTO proxy_usage
|
||||||
rusqlite::params![provider_id, app_type],
|
(provider_id, app_type, endpoint, request_tokens, response_tokens,
|
||||||
)
|
status_code, latency_ms, error, timestamp)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
|
||||||
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空所有Provider健康状态(代理停止时调用)
|
|
||||||
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM provider_health", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::debug!("Cleared all provider health records");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Circuit Breaker Config ====================
|
|
||||||
|
|
||||||
/// 获取熔断器配置
|
|
||||||
pub async fn get_circuit_breaker_config(
|
|
||||||
&self,
|
|
||||||
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let config = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT failure_threshold, success_threshold, timeout_seconds,
|
|
||||||
error_rate_threshold, min_requests
|
|
||||||
FROM circuit_breaker_config WHERE id = 1",
|
|
||||||
[],
|
|
||||||
|row| {
|
|
||||||
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
|
||||||
failure_threshold: row.get::<_, i32>(0)? as u32,
|
|
||||||
success_threshold: row.get::<_, i32>(1)? as u32,
|
|
||||||
timeout_seconds: row.get::<_, i64>(2)? as u64,
|
|
||||||
error_rate_threshold: row.get(3)?,
|
|
||||||
min_requests: row.get::<_, i32>(4)? as u32,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新熔断器配置
|
|
||||||
pub async fn update_circuit_breaker_config(
|
|
||||||
&self,
|
|
||||||
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE circuit_breaker_config
|
|
||||||
SET failure_threshold = ?1,
|
|
||||||
success_threshold = ?2,
|
|
||||||
timeout_seconds = ?3,
|
|
||||||
error_rate_threshold = ?4,
|
|
||||||
min_requests = ?5,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = 1",
|
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
config.failure_threshold as i32,
|
&record.provider_id,
|
||||||
config.success_threshold as i32,
|
&record.app_type,
|
||||||
config.timeout_seconds as i64,
|
&record.endpoint,
|
||||||
config.error_rate_threshold,
|
record.request_tokens,
|
||||||
config.min_requests as i32,
|
record.response_tokens,
|
||||||
|
record.status_code as i64,
|
||||||
|
record.latency_ms as i64,
|
||||||
|
&record.error,
|
||||||
|
&record.timestamp,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -299,73 +194,51 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Live Backup ====================
|
/// 查询最近的使用统计
|
||||||
|
#[allow(dead_code)]
|
||||||
/// 保存 Live 配置备份
|
pub async fn get_recent_usage(
|
||||||
pub async fn save_live_backup(
|
|
||||||
&self,
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
config_json: &str,
|
limit: usize,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<Vec<ProxyUsageRecord>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO proxy_live_backup (app_type, original_config, backed_up_at)
|
|
||||||
VALUES (?1, ?2, ?3)",
|
|
||||||
rusqlite::params![app_type, config_json, now],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::info!("已备份 {app_type} Live 配置");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Live 配置备份
|
|
||||||
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let result = conn.query_row(
|
let mut stmt = conn
|
||||||
"SELECT app_type, original_config, backed_up_at FROM proxy_live_backup WHERE app_type = ?1",
|
.prepare(
|
||||||
rusqlite::params![app_type],
|
"SELECT provider_id, app_type, endpoint, request_tokens, response_tokens,
|
||||||
|row| {
|
status_code, latency_ms, error, timestamp
|
||||||
Ok(LiveBackup {
|
FROM proxy_usage
|
||||||
app_type: row.get(0)?,
|
WHERE provider_id = ?1 AND app_type = ?2
|
||||||
original_config: row.get(1)?,
|
ORDER BY timestamp DESC
|
||||||
backed_up_at: row.get(2)?,
|
LIMIT ?3",
|
||||||
})
|
)
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(backup) => Ok(Some(backup)),
|
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
||||||
Err(e) => Err(AppError::Database(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除 Live 配置备份
|
|
||||||
pub async fn delete_live_backup(&self, app_type: &str) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM proxy_live_backup WHERE app_type = ?1",
|
|
||||||
rusqlite::params![app_type],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
log::info!("已删除 {app_type} Live 配置备份");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除所有 Live 配置备份
|
|
||||||
pub async fn delete_all_live_backups(&self) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM proxy_live_backup", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
log::info!("已删除所有 Live 配置备份");
|
let rows = stmt
|
||||||
Ok(())
|
.query_map(
|
||||||
|
rusqlite::params![provider_id, app_type, limit as i64],
|
||||||
|
|row| {
|
||||||
|
Ok(ProxyUsageRecord {
|
||||||
|
provider_id: row.get(0)?,
|
||||||
|
app_type: row.get(1)?,
|
||||||
|
endpoint: row.get(2)?,
|
||||||
|
request_tokens: row.get(3)?,
|
||||||
|
response_tokens: row.get(4)?,
|
||||||
|
status_code: row.get::<_, i64>(5)? as u16,
|
||||||
|
latency_ms: row.get::<_, i64>(6)? as u64,
|
||||||
|
error: row.get(7)?,
|
||||||
|
timestamp: row.get(8)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
records.push(row.map_err(|e| AppError::Database(e.to_string()))?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(records)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,18 @@ impl Database {
|
|||||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
|
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
let skill_iter = stmt
|
let skill_iter = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
let directory: String = row.get(0)?;
|
let key: String = row.get(0)?;
|
||||||
let app_type: String = row.get(1)?;
|
let installed: bool = row.get(1)?;
|
||||||
let installed: bool = row.get(2)?;
|
let installed_at_ts: i64 = row.get(2)?;
|
||||||
let installed_at_ts: i64 = row.get(3)?;
|
|
||||||
|
|
||||||
let installed_at =
|
let installed_at =
|
||||||
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
|
||||||
|
|
||||||
// 构建复合 key:"app_type:directory"
|
|
||||||
let key = format!("{app_type}:{directory}");
|
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
key,
|
key,
|
||||||
SkillState {
|
SkillState {
|
||||||
@@ -48,21 +44,11 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 Skill 状态
|
/// 更新 Skill 状态
|
||||||
/// key 格式为 "app_type:directory"
|
|
||||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
||||||
// 解析 key
|
|
||||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
|
||||||
let (app, dir) = key.split_at(idx);
|
|
||||||
(app, &dir[1..]) // 跳过冒号
|
|
||||||
} else {
|
|
||||||
// 向后兼容:如果没有前缀,默认为 claude
|
|
||||||
("claude", key)
|
|
||||||
};
|
|
||||||
|
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||||
params![directory, app_type, state.installed, state.installed_at.timestamp()],
|
params![key, state.installed, state.installed_at.timestamp()],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
//! 流式健康检查日志 DAO
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::services::stream_check::{StreamCheckConfig, StreamCheckResult};
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 保存流式检查日志
|
|
||||||
pub fn save_stream_check_log(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
provider_name: &str,
|
|
||||||
app_type: &str,
|
|
||||||
result: &StreamCheckResult,
|
|
||||||
) -> Result<i64, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO stream_check_logs
|
|
||||||
(provider_id, provider_name, app_type, status, success, message,
|
|
||||||
response_time_ms, http_status, model_used, retry_count, tested_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
||||||
rusqlite::params![
|
|
||||||
provider_id,
|
|
||||||
provider_name,
|
|
||||||
app_type,
|
|
||||||
format!("{:?}", result.status).to_lowercase(),
|
|
||||||
result.success,
|
|
||||||
result.message,
|
|
||||||
result.response_time_ms.map(|t| t as i64),
|
|
||||||
result.http_status.map(|s| s as i64),
|
|
||||||
result.model_used,
|
|
||||||
result.retry_count as i64,
|
|
||||||
result.tested_at,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取流式检查配置
|
|
||||||
pub fn get_stream_check_config(&self) -> Result<StreamCheckConfig, AppError> {
|
|
||||||
match self.get_setting("stream_check_config")? {
|
|
||||||
Some(json) => serde_json::from_str(&json)
|
|
||||||
.map_err(|e| AppError::Message(format!("解析配置失败: {e}"))),
|
|
||||||
None => Ok(StreamCheckConfig::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存流式检查配置
|
|
||||||
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
|
|
||||||
let json = serde_json::to_string(config)
|
|
||||||
.map_err(|e| AppError::Message(format!("序列化配置失败: {e}")))?;
|
|
||||||
self.set_setting("stream_check_config", &json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,9 +31,6 @@ mod schema;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
// DAO 类型导出供外部使用
|
|
||||||
pub use dao::FailoverQueueItem;
|
|
||||||
|
|
||||||
use crate::config::get_app_config_dir;
|
use crate::config::get_app_config_dir;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|||||||
@@ -31,12 +31,19 @@ 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,
|
||||||
|
is_proxy_target BOOLEAN NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id, app_type)
|
PRIMARY KEY (id, app_type)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 尝试添加 is_proxy_target 列(如果表已存在但缺少该列)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE providers ADD COLUMN is_proxy_target BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Provider Endpoints 表
|
// 2. Provider Endpoints 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS provider_endpoints (
|
"CREATE TABLE IF NOT EXISTS provider_endpoints (
|
||||||
@@ -89,11 +96,9 @@ impl Database {
|
|||||||
// 5. Skills 表
|
// 5. Skills 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skills (
|
"CREATE TABLE IF NOT EXISTS skills (
|
||||||
directory TEXT NOT NULL,
|
key TEXT PRIMARY KEY,
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
installed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
installed_at INTEGER NOT NULL DEFAULT 0
|
||||||
PRIMARY KEY (directory, app_type)
|
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -166,7 +171,40 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 10. Proxy Request Logs 表 (详细请求日志)
|
// 10. Proxy Usage 表 (代理使用统计,可选)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS proxy_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
request_tokens INTEGER,
|
||||||
|
response_tokens INTEGER,
|
||||||
|
status_code INTEGER NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
timestamp TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 为 proxy_usage 创建索引
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_timestamp
|
||||||
|
ON proxy_usage(timestamp)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_provider
|
||||||
|
ON proxy_usage(provider_id, app_type)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 11. Proxy Request Logs 表 (详细请求日志)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
request_id TEXT PRIMARY KEY,
|
request_id TEXT PRIMARY KEY,
|
||||||
@@ -232,7 +270,7 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 11. Model Pricing 表 (模型定价)
|
// 12. 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,
|
||||||
@@ -246,20 +284,38 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 12. Stream Check Logs 表 (流式健康检查日志)
|
// 13. Usage Daily Stats 表 (每日聚合统计)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS stream_check_logs (
|
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, provider_id, app_type, model)
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 14. Model Test Logs 表 (模型测试日志,独立于代理使用统计)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS model_test_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
provider_id TEXT NOT NULL,
|
provider_id TEXT NOT NULL,
|
||||||
provider_name TEXT NOT NULL,
|
provider_name TEXT NOT NULL,
|
||||||
app_type TEXT NOT NULL,
|
app_type TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
success INTEGER NOT NULL,
|
success INTEGER NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
response_time_ms INTEGER,
|
response_time_ms INTEGER,
|
||||||
http_status INTEGER,
|
http_status INTEGER,
|
||||||
model_used TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
tested_at INTEGER NOT NULL
|
tested_at INTEGER NOT NULL
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -267,71 +323,15 @@ impl Database {
|
|||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.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_model_test_logs_provider
|
||||||
ON stream_check_logs(app_type, provider_id, tested_at DESC)",
|
ON model_test_logs(provider_id, app_type)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 13. Circuit Breaker Config 表 (熔断器配置)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
|
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_tested_at
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
ON model_test_logs(tested_at DESC)",
|
||||||
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 配置备份)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
|
|
||||||
app_type TEXT PRIMARY KEY,
|
|
||||||
original_config TEXT NOT NULL,
|
|
||||||
backed_up_at TEXT NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 14. Failover Queue 表 (故障转移队列)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS failover_queue (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
queue_order INTEGER NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
UNIQUE (app_type, provider_id),
|
|
||||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 为故障转移队列创建索引
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
|
|
||||||
ON failover_queue(app_type, queue_order)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -369,9 +369,7 @@ impl Database {
|
|||||||
Self::set_user_version(conn, 1)?;
|
Self::set_user_version(conn, 1)?;
|
||||||
}
|
}
|
||||||
1 => {
|
1 => {
|
||||||
log::info!(
|
log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
|
||||||
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
|
|
||||||
);
|
|
||||||
Self::migrate_v1_to_v2(conn)?;
|
Self::migrate_v1_to_v2(conn)?;
|
||||||
Self::set_user_version(conn, 2)?;
|
Self::set_user_version(conn, 2)?;
|
||||||
}
|
}
|
||||||
@@ -460,7 +458,7 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表
|
/// v1 -> v2 迁移:添加使用统计表和完整字段
|
||||||
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
|
||||||
// providers 表字段
|
// providers 表字段
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
@@ -533,87 +531,29 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// usage_daily_stats 表
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, provider_id, app_type, model)
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
// 清空并重新插入模型定价
|
// 清空并重新插入模型定价
|
||||||
conn.execute("DELETE FROM model_pricing", [])
|
conn.execute("DELETE FROM model_pricing", [])
|
||||||
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
|
||||||
Self::seed_model_pricing(conn)?;
|
Self::seed_model_pricing(conn)?;
|
||||||
|
|
||||||
// 重构 skills 表(添加 app_type 字段)
|
|
||||||
Self::migrate_skills_table(conn)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
|
|
||||||
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
|
|
||||||
// 检查是否已经是新表结构
|
|
||||||
if Self::has_column(conn, "skills", "app_type")? {
|
|
||||||
log::info!("skills 表已经包含 app_type 字段,跳过迁移");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("开始迁移 skills 表...");
|
|
||||||
|
|
||||||
// 1. 重命名旧表
|
|
||||||
conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 2. 创建新表
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE skills (
|
|
||||||
directory TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (directory, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
// 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo")
|
|
||||||
// 旧数据如果没有前缀,默认为 claude
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT key, installed, installed_at FROM skills_old")
|
|
||||||
.map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
|
|
||||||
|
|
||||||
let old_skills: Vec<(String, bool, i64)> = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
Ok((
|
|
||||||
row.get::<_, String>(0)?,
|
|
||||||
row.get::<_, bool>(1)?,
|
|
||||||
row.get::<_, i64>(2)?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
|
|
||||||
|
|
||||||
let count = old_skills.len();
|
|
||||||
|
|
||||||
for (key, installed, installed_at) in old_skills {
|
|
||||||
// 解析 key: "app:directory" 或 "directory"(默认 claude)
|
|
||||||
let (app_type, directory) = if let Some(idx) = key.find(':') {
|
|
||||||
let (app, dir) = key.split_at(idx);
|
|
||||||
(app.to_string(), dir[1..].to_string()) // 跳过冒号
|
|
||||||
} else {
|
|
||||||
("claude".to_string(), key.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
rusqlite::params![directory, app_type, installed, installed_at],
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
|
||||||
AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 删除旧表
|
|
||||||
conn.execute("DROP TABLE skills_old", [])
|
|
||||||
.map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
|
|
||||||
|
|
||||||
log::info!("skills 表迁移完成,共迁移 {count} 条记录");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
is_proxy_target: Some(false),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -113,27 +113,4 @@ pub struct DeepLinkImportRequest {
|
|||||||
/// Remote config URL
|
/// Remote config URL
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub config_url: Option<String>,
|
pub config_url: Option<String>,
|
||||||
|
|
||||||
// ============ Usage script fields (v3.9+) ============
|
|
||||||
/// Whether to enable usage query (default: true if usage_script is provided)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_enabled: Option<bool>,
|
|
||||||
/// Base64 encoded usage query script code
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_script: Option<String>,
|
|
||||||
/// Usage query API key (if different from provider API key)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_api_key: Option<String>,
|
|
||||||
/// Usage query base URL (if different from provider endpoint)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_base_url: Option<String>,
|
|
||||||
/// Usage query access token (for NewAPI template)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_access_token: Option<String>,
|
|
||||||
/// Usage query user ID (for NewAPI template)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_user_id: Option<String>,
|
|
||||||
/// Auto query interval in minutes (0 to disable)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage_auto_interval: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,19 +122,6 @@ fn parse_provider_deeplink(
|
|||||||
let config_url = params.get("configUrl").cloned();
|
let config_url = params.get("configUrl").cloned();
|
||||||
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
|
||||||
|
|
||||||
// Extract usage script fields (v3.9+)
|
|
||||||
let usage_enabled = params
|
|
||||||
.get("usageEnabled")
|
|
||||||
.and_then(|v| v.parse::<bool>().ok());
|
|
||||||
let usage_script = params.get("usageScript").cloned();
|
|
||||||
let usage_api_key = params.get("usageApiKey").cloned();
|
|
||||||
let usage_base_url = params.get("usageBaseUrl").cloned();
|
|
||||||
let usage_access_token = params.get("usageAccessToken").cloned();
|
|
||||||
let usage_user_id = params.get("usageUserId").cloned();
|
|
||||||
let usage_auto_interval = params
|
|
||||||
.get("usageAutoInterval")
|
|
||||||
.and_then(|v| v.parse::<u64>().ok());
|
|
||||||
|
|
||||||
Ok(DeepLinkImportRequest {
|
Ok(DeepLinkImportRequest {
|
||||||
version,
|
version,
|
||||||
resource,
|
resource,
|
||||||
@@ -159,13 +146,6 @@ fn parse_provider_deeplink(
|
|||||||
config,
|
config,
|
||||||
config_format,
|
config_format,
|
||||||
config_url,
|
config_url,
|
||||||
usage_enabled,
|
|
||||||
usage_script,
|
|
||||||
usage_api_key,
|
|
||||||
usage_base_url,
|
|
||||||
usage_access_token,
|
|
||||||
usage_user_id,
|
|
||||||
usage_auto_interval,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,13 +206,6 @@ fn parse_prompt_deeplink(
|
|||||||
config: None,
|
config: None,
|
||||||
config_format: None,
|
config_format: None,
|
||||||
config_url: None,
|
config_url: None,
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,13 +261,6 @@ fn parse_mcp_deeplink(
|
|||||||
directory: None,
|
directory: None,
|
||||||
branch: None,
|
branch: None,
|
||||||
config_url: None,
|
config_url: None,
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,12 +309,5 @@ fn parse_skill_deeplink(
|
|||||||
config: None,
|
config: None,
|
||||||
config_format: None,
|
config_format: None,
|
||||||
config_url: None,
|
config_url: None,
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
||||||
use super::DeepLinkImportRequest;
|
use super::DeepLinkImportRequest;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
use crate::provider::Provider;
|
||||||
use crate::services::ProviderService;
|
use crate::services::ProviderService;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use crate::AppType;
|
use crate::AppType;
|
||||||
@@ -117,9 +117,6 @@ pub(crate) fn build_provider_from_request(
|
|||||||
AppType::Gemini => build_gemini_settings(request),
|
AppType::Gemini => build_gemini_settings(request),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build usage script configuration if provided
|
|
||||||
let meta = build_provider_meta(request)?;
|
|
||||||
|
|
||||||
let provider = Provider {
|
let provider = Provider {
|
||||||
id: String::new(), // Will be generated by caller
|
id: String::new(), // Will be generated by caller
|
||||||
name: request.name.clone().unwrap_or_default(),
|
name: request.name.clone().unwrap_or_default(),
|
||||||
@@ -129,65 +126,15 @@ pub(crate) fn build_provider_from_request(
|
|||||||
created_at: None,
|
created_at: None,
|
||||||
sort_index: None,
|
sort_index: None,
|
||||||
notes: request.notes.clone(),
|
notes: request.notes.clone(),
|
||||||
meta,
|
meta: None,
|
||||||
icon: request.icon.clone(),
|
icon: request.icon.clone(),
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
is_proxy_target: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(provider)
|
Ok(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build provider meta with usage script configuration
|
|
||||||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
|
||||||
// Check if any usage script fields are provided
|
|
||||||
if request.usage_script.is_none()
|
|
||||||
&& request.usage_enabled.is_none()
|
|
||||||
&& request.usage_api_key.is_none()
|
|
||||||
&& request.usage_base_url.is_none()
|
|
||||||
&& request.usage_access_token.is_none()
|
|
||||||
&& request.usage_user_id.is_none()
|
|
||||||
&& request.usage_auto_interval.is_none()
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode usage script code if provided
|
|
||||||
let code = if let Some(script_b64) = &request.usage_script {
|
|
||||||
let decoded = decode_base64_param("usage_script", script_b64)?;
|
|
||||||
String::from_utf8(decoded)
|
|
||||||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine enabled state: explicit param > has code > false
|
|
||||||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
|
||||||
|
|
||||||
// Build UsageScript - use provider's API key and endpoint as defaults
|
|
||||||
let usage_script = UsageScript {
|
|
||||||
enabled,
|
|
||||||
language: "javascript".to_string(),
|
|
||||||
code,
|
|
||||||
timeout: Some(10),
|
|
||||||
api_key: request
|
|
||||||
.usage_api_key
|
|
||||||
.clone()
|
|
||||||
.or_else(|| request.api_key.clone()),
|
|
||||||
base_url: request
|
|
||||||
.usage_base_url
|
|
||||||
.clone()
|
|
||||||
.or_else(|| request.endpoint.clone()),
|
|
||||||
access_token: request.usage_access_token.clone(),
|
|
||||||
user_id: request.usage_user_id.clone(),
|
|
||||||
auto_query_interval: request.usage_auto_interval,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(ProviderMeta {
|
|
||||||
usage_script: Some(usage_script),
|
|
||||||
..Default::default()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build Claude settings configuration
|
/// Build Claude settings configuration
|
||||||
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||||||
let mut env = serde_json::Map::new();
|
let mut env = serde_json::Map::new();
|
||||||
|
|||||||
@@ -145,13 +145,6 @@ fn test_build_gemini_provider_with_model() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
@@ -198,13 +191,6 @@ fn test_build_gemini_provider_without_model() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||||||
@@ -246,13 +232,6 @@ fn test_parse_and_merge_config_claude() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let merged = parse_and_merge_config(&request).unwrap();
|
let merged = parse_and_merge_config(&request).unwrap();
|
||||||
@@ -296,13 +275,6 @@ fn test_parse_and_merge_config_url_override() {
|
|||||||
content: None,
|
content: None,
|
||||||
description: None,
|
description: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
usage_enabled: None,
|
|
||||||
usage_script: None,
|
|
||||||
usage_api_key: None,
|
|
||||||
usage_base_url: None,
|
|
||||||
usage_access_token: None,
|
|
||||||
usage_user_id: None,
|
|
||||||
usage_auto_interval: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let merged = parse_and_merge_config(&request).unwrap();
|
let merged = parse_and_merge_config(&request).unwrap();
|
||||||
|
|||||||
+14
-114
@@ -39,8 +39,8 @@ pub use mcp::{
|
|||||||
};
|
};
|
||||||
pub use provider::{Provider, ProviderMeta};
|
pub use provider::{Provider, ProviderMeta};
|
||||||
pub use services::{
|
pub use services::{
|
||||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SkillService,
|
||||||
SkillService, SpeedtestService,
|
SpeedtestService,
|
||||||
};
|
};
|
||||||
pub use settings::{update_settings, AppSettings};
|
pub use settings::{update_settings, AppSettings};
|
||||||
pub use store::AppState;
|
pub use store::AppState;
|
||||||
@@ -332,9 +332,6 @@ pub fn run() {
|
|||||||
|
|
||||||
let app_state = AppState::new(db);
|
let app_state = AppState::new(db);
|
||||||
|
|
||||||
// 设置 AppHandle 用于代理故障转移时的 UI 更新
|
|
||||||
app_state.proxy_service.set_app_handle(app.handle().clone());
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
|
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -525,38 +522,15 @@ 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 配置
|
|
||||||
match state.db.is_live_takeover_active().await {
|
|
||||||
Ok(true) => {
|
|
||||||
// 接管标志为 true 但代理未运行 → 上次异常退出
|
|
||||||
if !state.proxy_service.is_running().await {
|
|
||||||
log::warn!("检测到上次异常退出,正在恢复 Live 配置...");
|
|
||||||
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
|
||||||
log::error!("恢复 Live 配置失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!("Live 配置已从异常退出中恢复");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false) => {
|
|
||||||
// 正常状态,无需恢复
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("检查接管状态失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 自动启动代理服务器(如果配置为启用)
|
|
||||||
match state.db.get_proxy_config().await {
|
match state.db.get_proxy_config().await {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
if config.enabled {
|
if config.enabled {
|
||||||
log::info!("代理服务配置为启用,正在启动...");
|
log::info!("代理服务配置为启用,正在启动...");
|
||||||
match state.proxy_service.start_with_takeover().await {
|
match state.proxy_service.start().await {
|
||||||
Ok(info) => log::info!(
|
Ok(info) => log::info!(
|
||||||
"代理服务器自动启动成功: {}:{}",
|
"代理服务器自动启动成功: {}:{}",
|
||||||
info.address,
|
info.address,
|
||||||
@@ -579,6 +553,7 @@ pub fn run() {
|
|||||||
commands::update_provider,
|
commands::update_provider,
|
||||||
commands::delete_provider,
|
commands::delete_provider,
|
||||||
commands::switch_provider,
|
commands::switch_provider,
|
||||||
|
commands::set_proxy_target_provider,
|
||||||
commands::import_default_config,
|
commands::import_default_config,
|
||||||
commands::get_claude_config_status,
|
commands::get_claude_config_status,
|
||||||
commands::get_config_status,
|
commands::get_config_status,
|
||||||
@@ -660,11 +635,8 @@ pub fn run() {
|
|||||||
commands::restore_env_backup,
|
commands::restore_env_backup,
|
||||||
// Skill management
|
// Skill management
|
||||||
commands::get_skills,
|
commands::get_skills,
|
||||||
commands::get_skills_for_app,
|
|
||||||
commands::install_skill,
|
commands::install_skill,
|
||||||
commands::install_skill_for_app,
|
|
||||||
commands::uninstall_skill,
|
commands::uninstall_skill,
|
||||||
commands::uninstall_skill_for_app,
|
|
||||||
commands::get_skill_repos,
|
commands::get_skill_repos,
|
||||||
commands::add_skill_repo,
|
commands::add_skill_repo,
|
||||||
commands::remove_skill_repo,
|
commands::remove_skill_repo,
|
||||||
@@ -672,27 +644,12 @@ pub fn run() {
|
|||||||
commands::set_auto_launch,
|
commands::set_auto_launch,
|
||||||
commands::get_auto_launch_status,
|
commands::get_auto_launch_status,
|
||||||
// Proxy server management
|
// Proxy server management
|
||||||
commands::start_proxy_with_takeover,
|
commands::start_proxy_server,
|
||||||
commands::stop_proxy_with_restore,
|
commands::stop_proxy_server,
|
||||||
commands::get_proxy_status,
|
commands::get_proxy_status,
|
||||||
commands::get_proxy_config,
|
commands::get_proxy_config,
|
||||||
commands::update_proxy_config,
|
commands::update_proxy_config,
|
||||||
commands::is_proxy_running,
|
commands::is_proxy_running,
|
||||||
commands::is_live_takeover_active,
|
|
||||||
commands::switch_proxy_provider,
|
|
||||||
// Proxy failover commands
|
|
||||||
commands::get_provider_health,
|
|
||||||
commands::reset_circuit_breaker,
|
|
||||||
commands::get_circuit_breaker_config,
|
|
||||||
commands::update_circuit_breaker_config,
|
|
||||||
commands::get_circuit_breaker_stats,
|
|
||||||
// Failover queue management
|
|
||||||
commands::get_failover_queue,
|
|
||||||
commands::get_available_providers_for_failover,
|
|
||||||
commands::add_to_failover_queue,
|
|
||||||
commands::remove_from_failover_queue,
|
|
||||||
commands::reorder_failover_queue,
|
|
||||||
commands::set_failover_item_enabled,
|
|
||||||
// Usage statistics
|
// Usage statistics
|
||||||
commands::get_usage_summary,
|
commands::get_usage_summary,
|
||||||
commands::get_usage_trends,
|
commands::get_usage_trends,
|
||||||
@@ -704,12 +661,13 @@ pub fn run() {
|
|||||||
commands::update_model_pricing,
|
commands::update_model_pricing,
|
||||||
commands::delete_model_pricing,
|
commands::delete_model_pricing,
|
||||||
commands::check_provider_limits,
|
commands::check_provider_limits,
|
||||||
// Stream health check
|
// Model testing
|
||||||
commands::stream_check_provider,
|
commands::test_provider_model,
|
||||||
commands::stream_check_all_providers,
|
commands::test_all_providers_model,
|
||||||
commands::get_stream_check_config,
|
commands::get_model_test_config,
|
||||||
commands::save_stream_check_config,
|
commands::save_model_test_config,
|
||||||
commands::get_tool_versions,
|
commands::get_model_test_logs,
|
||||||
|
commands::cleanup_model_test_logs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
@@ -717,26 +675,6 @@ pub fn run() {
|
|||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
||||||
app.run(|app_handle, event| {
|
app.run(|app_handle, event| {
|
||||||
// 处理退出请求(所有平台)
|
|
||||||
if let RunEvent::ExitRequested { api, .. } = &event {
|
|
||||||
log::info!("收到退出请求,开始清理...");
|
|
||||||
// 阻止立即退出,执行清理
|
|
||||||
api.prevent_exit();
|
|
||||||
|
|
||||||
let app_handle = app_handle.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
cleanup_before_exit(&app_handle).await;
|
|
||||||
log::info!("清理完成,退出应用");
|
|
||||||
|
|
||||||
// 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
||||||
|
|
||||||
// 使用 std::process::exit 避免再次触发 ExitRequested
|
|
||||||
std::process::exit(0);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
match event {
|
match event {
|
||||||
@@ -816,44 +754,6 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 应用退出清理
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// 应用退出前的清理工作
|
|
||||||
///
|
|
||||||
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
|
|
||||||
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
|
|
||||||
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
|
||||||
if let Some(state) = app_handle.try_state::<store::AppState>() {
|
|
||||||
let proxy_service = &state.proxy_service;
|
|
||||||
|
|
||||||
// 检查代理是否在运行
|
|
||||||
if proxy_service.is_running().await {
|
|
||||||
log::info!("检测到代理服务器正在运行,开始清理...");
|
|
||||||
|
|
||||||
// 检查是否处于 Live 接管模式
|
|
||||||
if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
|
|
||||||
if is_takeover {
|
|
||||||
// 接管模式:停止并恢复配置
|
|
||||||
if let Err(e) = proxy_service.stop_with_restore().await {
|
|
||||||
log::error!("退出时恢复 Live 配置失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!("已恢复 Live 配置");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 非接管模式:仅停止代理
|
|
||||||
if let Err(e) = proxy_service.stop().await {
|
|
||||||
log::error!("退出时停止代理失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("代理服务器清理完成");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 迁移错误对话框辅助函数
|
// 迁移错误对话框辅助函数
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "isProxyTarget")]
|
||||||
|
pub is_proxy_target: Option<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,
|
||||||
|
is_proxy_target: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,418 +0,0 @@
|
|||||||
//! 熔断器模块
|
|
||||||
//!
|
|
||||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
/// 熔断器状态
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum CircuitState {
|
|
||||||
/// 关闭状态 - 正常工作
|
|
||||||
Closed,
|
|
||||||
/// 打开状态 - 熔断激活,拒绝请求
|
|
||||||
Open,
|
|
||||||
/// 半开状态 - 尝试恢复,允许部分请求通过
|
|
||||||
HalfOpen,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for CircuitState {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
CircuitState::Closed => write!(f, "closed"),
|
|
||||||
CircuitState::Open => write!(f, "open"),
|
|
||||||
CircuitState::HalfOpen => write!(f, "half_open"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 熔断器配置
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CircuitBreakerConfig {
|
|
||||||
/// 失败阈值 - 连续失败多少次后打开熔断器
|
|
||||||
pub failure_threshold: u32,
|
|
||||||
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
|
|
||||||
pub success_threshold: u32,
|
|
||||||
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
|
|
||||||
pub timeout_seconds: u64,
|
|
||||||
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
|
|
||||||
pub error_rate_threshold: f64,
|
|
||||||
/// 最小请求数 - 计算错误率前的最小请求数
|
|
||||||
pub min_requests: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CircuitBreakerConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
failure_threshold: 5,
|
|
||||||
success_threshold: 2,
|
|
||||||
timeout_seconds: 60,
|
|
||||||
error_rate_threshold: 0.5,
|
|
||||||
min_requests: 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 熔断器实例
|
|
||||||
pub struct CircuitBreaker {
|
|
||||||
/// 当前状态
|
|
||||||
state: Arc<RwLock<CircuitState>>,
|
|
||||||
/// 连续失败计数
|
|
||||||
consecutive_failures: Arc<AtomicU32>,
|
|
||||||
/// 连续成功计数(半开状态)
|
|
||||||
consecutive_successes: Arc<AtomicU32>,
|
|
||||||
/// 总请求计数
|
|
||||||
total_requests: Arc<AtomicU32>,
|
|
||||||
/// 失败请求计数
|
|
||||||
failed_requests: Arc<AtomicU32>,
|
|
||||||
/// 上次打开时间
|
|
||||||
last_opened_at: Arc<RwLock<Option<Instant>>>,
|
|
||||||
/// 配置(支持热更新)
|
|
||||||
config: Arc<RwLock<CircuitBreakerConfig>>,
|
|
||||||
/// 半开状态已放行的请求数(用于限流)
|
|
||||||
half_open_requests: Arc<AtomicU32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CircuitBreaker {
|
|
||||||
/// 创建新的熔断器
|
|
||||||
pub fn new(config: CircuitBreakerConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
state: Arc::new(RwLock::new(CircuitState::Closed)),
|
|
||||||
consecutive_failures: Arc::new(AtomicU32::new(0)),
|
|
||||||
consecutive_successes: Arc::new(AtomicU32::new(0)),
|
|
||||||
total_requests: Arc::new(AtomicU32::new(0)),
|
|
||||||
failed_requests: Arc::new(AtomicU32::new(0)),
|
|
||||||
last_opened_at: Arc::new(RwLock::new(None)),
|
|
||||||
config: Arc::new(RwLock::new(config)),
|
|
||||||
half_open_requests: Arc::new(AtomicU32::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新熔断器配置(热更新,不重置状态)
|
|
||||||
pub async fn update_config(&self, new_config: CircuitBreakerConfig) {
|
|
||||||
*self.config.write().await = new_config;
|
|
||||||
log::debug!("Circuit breaker config updated");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
|
||||||
///
|
|
||||||
/// 这个方法不会占用 HalfOpen 探测名额,仅用于路由选择阶段的“可用性判断”:
|
|
||||||
/// - Closed / HalfOpen:可用(返回 true)
|
|
||||||
/// - Open:若超时到达则切到 HalfOpen 并返回 true,否则返回 false
|
|
||||||
///
|
|
||||||
/// 注意:真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额,
|
|
||||||
/// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。
|
|
||||||
pub async fn is_available(&self) -> bool {
|
|
||||||
let state = *self.state.read().await;
|
|
||||||
let config = self.config.read().await;
|
|
||||||
|
|
||||||
match state {
|
|
||||||
CircuitState::Closed | CircuitState::HalfOpen => true,
|
|
||||||
CircuitState::Open => {
|
|
||||||
if let Some(opened_at) = *self.last_opened_at.read().await {
|
|
||||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
|
||||||
drop(config); // 释放读锁再转换状态
|
|
||||||
log::info!(
|
|
||||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
|
||||||
);
|
|
||||||
self.transition_to_half_open().await;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否允许请求通过
|
|
||||||
pub async fn allow_request(&self) -> bool {
|
|
||||||
let state = *self.state.read().await;
|
|
||||||
let config = self.config.read().await;
|
|
||||||
|
|
||||||
match state {
|
|
||||||
CircuitState::Closed => true,
|
|
||||||
CircuitState::Open => {
|
|
||||||
// 检查是否应该尝试半开
|
|
||||||
if let Some(opened_at) = *self.last_opened_at.read().await {
|
|
||||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
|
||||||
drop(config); // 释放读锁再转换状态
|
|
||||||
log::info!(
|
|
||||||
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
|
|
||||||
);
|
|
||||||
self.transition_to_half_open().await;
|
|
||||||
// 增加计数,确保 record_success/record_failure 减计数时不会下溢
|
|
||||||
self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
CircuitState::HalfOpen => {
|
|
||||||
// 半开状态限流:只允许有限请求通过进行探测
|
|
||||||
// 默认最多允许 1 个请求(可在配置中扩展)
|
|
||||||
let max_half_open_requests = 1u32;
|
|
||||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
if current < max_half_open_requests {
|
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
|
|
||||||
current + 1,
|
|
||||||
max_half_open_requests
|
|
||||||
);
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
// 超过限额,回退计数,拒绝请求
|
|
||||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记录成功
|
|
||||||
pub async fn record_success(&self) {
|
|
||||||
let state = *self.state.read().await;
|
|
||||||
let config = self.config.read().await;
|
|
||||||
|
|
||||||
// 重置失败计数
|
|
||||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
|
||||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
match state {
|
|
||||||
CircuitState::HalfOpen => {
|
|
||||||
// 释放 in-flight 名额(探测请求结束)
|
|
||||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
|
|
||||||
successes,
|
|
||||||
config.success_threshold
|
|
||||||
);
|
|
||||||
|
|
||||||
if successes >= config.success_threshold {
|
|
||||||
drop(config); // 释放读锁再转换状态
|
|
||||||
log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)");
|
|
||||||
self.transition_to_closed().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CircuitState::Closed => {
|
|
||||||
log::debug!("Circuit breaker Closed: request succeeded");
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记录失败
|
|
||||||
pub async fn record_failure(&self) {
|
|
||||||
let state = *self.state.read().await;
|
|
||||||
let config = self.config.read().await;
|
|
||||||
|
|
||||||
// 更新计数器
|
|
||||||
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
|
|
||||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
self.failed_requests.fetch_add(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// 重置成功计数
|
|
||||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker {:?}: {} consecutive failures (threshold: {})",
|
|
||||||
state,
|
|
||||||
failures,
|
|
||||||
config.failure_threshold
|
|
||||||
);
|
|
||||||
|
|
||||||
// 检查是否应该打开熔断器
|
|
||||||
match state {
|
|
||||||
CircuitState::HalfOpen => {
|
|
||||||
// 释放 in-flight 名额(探测请求结束)
|
|
||||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
// HalfOpen 状态下失败,立即转为 Open
|
|
||||||
log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
|
|
||||||
drop(config);
|
|
||||||
self.transition_to_open().await;
|
|
||||||
}
|
|
||||||
CircuitState::Closed => {
|
|
||||||
// 检查连续失败次数
|
|
||||||
if failures >= config.failure_threshold {
|
|
||||||
log::warn!(
|
|
||||||
"Circuit breaker opening due to {} consecutive failures (threshold: {})",
|
|
||||||
failures,
|
|
||||||
config.failure_threshold
|
|
||||||
);
|
|
||||||
drop(config); // 释放读锁再转换状态
|
|
||||||
self.transition_to_open().await;
|
|
||||||
} else {
|
|
||||||
// 检查错误率
|
|
||||||
let total = self.total_requests.load(Ordering::SeqCst);
|
|
||||||
let failed = self.failed_requests.load(Ordering::SeqCst);
|
|
||||||
|
|
||||||
if total >= config.min_requests {
|
|
||||||
let error_rate = failed as f64 / total as f64;
|
|
||||||
log::debug!(
|
|
||||||
"Circuit breaker error rate: {:.2}% ({}/{} requests)",
|
|
||||||
error_rate * 100.0,
|
|
||||||
failed,
|
|
||||||
total
|
|
||||||
);
|
|
||||||
|
|
||||||
if error_rate >= config.error_rate_threshold {
|
|
||||||
log::warn!(
|
|
||||||
"Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)",
|
|
||||||
error_rate * 100.0,
|
|
||||||
config.error_rate_threshold * 100.0
|
|
||||||
);
|
|
||||||
drop(config); // 释放读锁再转换状态
|
|
||||||
self.transition_to_open().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取当前状态
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn get_state(&self) -> CircuitState {
|
|
||||||
*self.state.read().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取统计信息
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn get_stats(&self) -> CircuitBreakerStats {
|
|
||||||
CircuitBreakerStats {
|
|
||||||
state: *self.state.read().await,
|
|
||||||
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
|
|
||||||
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
|
|
||||||
total_requests: self.total_requests.load(Ordering::SeqCst),
|
|
||||||
failed_requests: self.failed_requests.load(Ordering::SeqCst),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置熔断器(手动恢复)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn reset(&self) {
|
|
||||||
log::info!("Circuit breaker manually reset to Closed state");
|
|
||||||
self.transition_to_closed().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换到打开状态
|
|
||||||
async fn transition_to_open(&self) {
|
|
||||||
*self.state.write().await = CircuitState::Open;
|
|
||||||
*self.last_opened_at.write().await = Some(Instant::now());
|
|
||||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
|
||||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换到半开状态
|
|
||||||
async fn transition_to_half_open(&self) {
|
|
||||||
*self.state.write().await = CircuitState::HalfOpen;
|
|
||||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
|
||||||
// 重置半开状态的请求限流计数
|
|
||||||
self.half_open_requests.store(0, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换到关闭状态
|
|
||||||
async fn transition_to_closed(&self) {
|
|
||||||
*self.state.write().await = CircuitState::Closed;
|
|
||||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
|
||||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
|
||||||
// 重置计数器
|
|
||||||
self.total_requests.store(0, Ordering::SeqCst);
|
|
||||||
self.failed_requests.store(0, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 熔断器统计信息
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct CircuitBreakerStats {
|
|
||||||
pub state: CircuitState,
|
|
||||||
pub consecutive_failures: u32,
|
|
||||||
pub consecutive_successes: u32,
|
|
||||||
pub total_requests: u32,
|
|
||||||
pub failed_requests: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_circuit_breaker_closed_to_open() {
|
|
||||||
let config = CircuitBreakerConfig {
|
|
||||||
failure_threshold: 3,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let breaker = CircuitBreaker::new(config);
|
|
||||||
|
|
||||||
// 初始状态应该是关闭
|
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
|
||||||
assert!(breaker.allow_request().await);
|
|
||||||
|
|
||||||
// 记录 3 次失败
|
|
||||||
for _ in 0..3 {
|
|
||||||
breaker.record_failure().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应该转换到打开状态
|
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
|
||||||
assert!(!breaker.allow_request().await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_circuit_breaker_half_open_to_closed() {
|
|
||||||
let config = CircuitBreakerConfig {
|
|
||||||
failure_threshold: 2,
|
|
||||||
success_threshold: 2,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let breaker = CircuitBreaker::new(config);
|
|
||||||
|
|
||||||
// 打开熔断器
|
|
||||||
breaker.record_failure().await;
|
|
||||||
breaker.record_failure().await;
|
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
|
||||||
|
|
||||||
// 手动转换到半开状态
|
|
||||||
breaker.transition_to_half_open().await;
|
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
|
|
||||||
|
|
||||||
// 记录 2 次成功
|
|
||||||
breaker.record_success().await;
|
|
||||||
breaker.record_success().await;
|
|
||||||
|
|
||||||
// 应该转换到关闭状态
|
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_circuit_breaker_reset() {
|
|
||||||
let config = CircuitBreakerConfig {
|
|
||||||
failure_threshold: 2,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let breaker = CircuitBreaker::new(config);
|
|
||||||
|
|
||||||
// 打开熔断器
|
|
||||||
breaker.record_failure().await;
|
|
||||||
breaker.record_failure().await;
|
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Open);
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
breaker.reset().await;
|
|
||||||
assert_eq!(breaker.get_state().await, CircuitState::Closed);
|
|
||||||
assert!(breaker.allow_request().await);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
//! 错误类型到 HTTP 状态码的映射
|
|
||||||
//!
|
|
||||||
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
|
|
||||||
|
|
||||||
use super::ProxyError;
|
|
||||||
|
|
||||||
/// 将 ProxyError 映射到 HTTP 状态码
|
|
||||||
///
|
|
||||||
/// 映射规则:
|
|
||||||
/// - 上游错误:直接使用上游返回的状态码
|
|
||||||
/// - 超时:504 Gateway Timeout
|
|
||||||
/// - 连接失败:502 Bad Gateway
|
|
||||||
/// - 无可用 Provider:503 Service Unavailable
|
|
||||||
/// - 重试耗尽:503 Service Unavailable
|
|
||||||
/// - 其他错误:500 Internal Server Error
|
|
||||||
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
|
||||||
match error {
|
|
||||||
// 上游错误:使用实际状态码
|
|
||||||
ProxyError::UpstreamError { status, .. } => *status,
|
|
||||||
|
|
||||||
// 超时错误:504 Gateway Timeout
|
|
||||||
ProxyError::Timeout(_) => 504,
|
|
||||||
|
|
||||||
// 转发失败/连接失败:502 Bad Gateway
|
|
||||||
ProxyError::ForwardFailed(_) => 502,
|
|
||||||
|
|
||||||
// 无可用 Provider:503 Service Unavailable
|
|
||||||
ProxyError::NoAvailableProvider => 503,
|
|
||||||
|
|
||||||
// 重试耗尽:503 Service Unavailable
|
|
||||||
ProxyError::MaxRetriesExceeded => 503,
|
|
||||||
|
|
||||||
// Provider 不健康:503 Service Unavailable
|
|
||||||
ProxyError::ProviderUnhealthy(_) => 503,
|
|
||||||
|
|
||||||
// 数据库错误:500 Internal Server Error
|
|
||||||
ProxyError::DatabaseError(_) => 500,
|
|
||||||
|
|
||||||
// 转换错误:500 Internal Server Error
|
|
||||||
ProxyError::TransformError(_) => 500,
|
|
||||||
|
|
||||||
// 其他未知错误:500 Internal Server Error
|
|
||||||
_ => 500,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 ProxyError 转换为用户友好的错误消息
|
|
||||||
pub fn get_error_message(error: &ProxyError) -> String {
|
|
||||||
match error {
|
|
||||||
ProxyError::UpstreamError { status, body } => {
|
|
||||||
if let Some(body) = body {
|
|
||||||
format!("上游错误 ({status}): {body}")
|
|
||||||
} else {
|
|
||||||
format!("上游错误 ({status})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
|
|
||||||
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
|
|
||||||
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
|
|
||||||
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
|
|
||||||
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
|
|
||||||
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
|
|
||||||
ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"),
|
|
||||||
_ => error.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_upstream_error() {
|
|
||||||
let error = ProxyError::UpstreamError {
|
|
||||||
status: 401,
|
|
||||||
body: Some("Unauthorized".to_string()),
|
|
||||||
};
|
|
||||||
assert_eq!(map_proxy_error_to_status(&error), 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_timeout_error() {
|
|
||||||
let error = ProxyError::Timeout("Request timeout".to_string());
|
|
||||||
assert_eq!(map_proxy_error_to_status(&error), 504);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_connection_error() {
|
|
||||||
let error = ProxyError::ForwardFailed("Connection refused".to_string());
|
|
||||||
assert_eq!(map_proxy_error_to_status(&error), 502);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_no_provider_error() {
|
|
||||||
let error = ProxyError::NoAvailableProvider;
|
|
||||||
assert_eq!(map_proxy_error_to_status(&error), 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_get_error_message() {
|
|
||||||
let error = ProxyError::UpstreamError {
|
|
||||||
status: 500,
|
|
||||||
body: Some("Internal Server Error".to_string()),
|
|
||||||
};
|
|
||||||
let msg = get_error_message(&error);
|
|
||||||
assert!(msg.contains("上游错误"));
|
|
||||||
assert!(msg.contains("500"));
|
|
||||||
assert!(msg.contains("Internal Server Error"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
//! 故障转移切换模块
|
|
||||||
//!
|
|
||||||
//! 处理故障转移成功后的供应商切换逻辑,包括:
|
|
||||||
//! - 去重控制(避免多个请求同时触发)
|
|
||||||
//! - 数据库更新
|
|
||||||
//! - 托盘菜单更新
|
|
||||||
//! - 前端事件发射
|
|
||||||
//! - Live 备份更新
|
|
||||||
|
|
||||||
use crate::database::Database;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tauri::{Emitter, Manager};
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
/// 故障转移切换管理器
|
|
||||||
///
|
|
||||||
/// 负责处理故障转移成功后的供应商切换,确保 UI 能够直观反映当前使用的供应商。
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct FailoverSwitchManager {
|
|
||||||
/// 正在处理中的切换(key = "app_type:provider_id")
|
|
||||||
pending_switches: Arc<RwLock<HashSet<String>>>,
|
|
||||||
db: Arc<Database>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FailoverSwitchManager {
|
|
||||||
pub fn new(db: Arc<Database>) -> Self {
|
|
||||||
Self {
|
|
||||||
pending_switches: Arc::new(RwLock::new(HashSet::new())),
|
|
||||||
db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 尝试执行故障转移切换
|
|
||||||
///
|
|
||||||
/// 如果相同的切换已在进行中,则跳过;否则执行切换逻辑。
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// - `Ok(true)` - 切换成功执行
|
|
||||||
/// - `Ok(false)` - 切换已在进行中,跳过
|
|
||||||
/// - `Err(e)` - 切换过程中发生错误
|
|
||||||
pub async fn try_switch(
|
|
||||||
&self,
|
|
||||||
app_handle: Option<&tauri::AppHandle>,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
provider_name: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
let switch_key = format!("{app_type}:{provider_id}");
|
|
||||||
|
|
||||||
// 去重检查:如果相同切换已在进行中,跳过
|
|
||||||
{
|
|
||||||
let mut pending = self.pending_switches.write().await;
|
|
||||||
if pending.contains(&switch_key) {
|
|
||||||
log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}");
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
pending.insert(switch_key.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行切换(确保最后清理 pending 标记)
|
|
||||||
let result = self
|
|
||||||
.do_switch(app_handle, app_type, provider_id, provider_name)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 清理 pending 标记
|
|
||||||
{
|
|
||||||
let mut pending = self.pending_switches.write().await;
|
|
||||||
pending.remove(&switch_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn do_switch(
|
|
||||||
&self,
|
|
||||||
app_handle: Option<&tauri::AppHandle>,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
provider_name: &str,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
|
|
||||||
|
|
||||||
// 1. 更新数据库 is_current
|
|
||||||
self.db.set_current_provider(app_type, provider_id)?;
|
|
||||||
|
|
||||||
// 2. 更新本地 settings(设备级)
|
|
||||||
let app_type_enum = crate::app_config::AppType::from_str(app_type)
|
|
||||||
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
|
|
||||||
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
|
|
||||||
|
|
||||||
// 3. 更新托盘菜单和发射事件
|
|
||||||
if let Some(app) = app_handle {
|
|
||||||
// 更新托盘菜单
|
|
||||||
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
|
|
||||||
// 更新 Live 备份(确保代理停止时恢复正确配置)
|
|
||||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
|
|
||||||
if let Err(e) = app_state
|
|
||||||
.proxy_service
|
|
||||||
.update_live_backup_from_provider(app_type, &provider)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!("[Failover] 更新 Live 备份失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重建托盘菜单
|
|
||||||
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
|
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
|
||||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
|
||||||
log::error!("[Failover] 更新托盘菜单失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发射事件到前端
|
|
||||||
let event_data = serde_json::json!({
|
|
||||||
"appType": app_type,
|
|
||||||
"providerId": provider_id,
|
|
||||||
"source": "failover" // 标识来源是故障转移
|
|
||||||
});
|
|
||||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
|
||||||
log::error!("[Failover] 发射供应商切换事件失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
error::*,
|
error::*,
|
||||||
failover_switch::FailoverSwitchManager,
|
|
||||||
provider_router::ProviderRouter,
|
|
||||||
providers::{get_adapter, ProviderAdapter},
|
providers::{get_adapter, ProviderAdapter},
|
||||||
|
router::ProviderRouter,
|
||||||
types::ProxyStatus,
|
types::ProxyStatus,
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{app_config::AppType, database::Database, provider::Provider};
|
||||||
use reqwest::{Client, Response};
|
use reqwest::{Client, Response};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -19,27 +18,17 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
pub struct RequestForwarder {
|
pub struct RequestForwarder {
|
||||||
client: Client,
|
client: Client,
|
||||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
router: ProviderRouter,
|
||||||
router: Arc<ProviderRouter>,
|
|
||||||
/// 单个 Provider 内的最大重试次数
|
|
||||||
max_retries: u8,
|
max_retries: u8,
|
||||||
status: Arc<RwLock<ProxyStatus>>,
|
status: Arc<RwLock<ProxyStatus>>,
|
||||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
|
||||||
/// 故障转移切换管理器
|
|
||||||
failover_manager: Arc<FailoverSwitchManager>,
|
|
||||||
/// AppHandle,用于发射事件和更新托盘
|
|
||||||
app_handle: Option<tauri::AppHandle>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestForwarder {
|
impl RequestForwarder {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
router: Arc<ProviderRouter>,
|
db: Arc<Database>,
|
||||||
timeout_secs: u64,
|
timeout_secs: u64,
|
||||||
max_retries: u8,
|
max_retries: u8,
|
||||||
status: Arc<RwLock<ProxyStatus>>,
|
status: Arc<RwLock<ProxyStatus>>,
|
||||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
|
||||||
failover_manager: Arc<FailoverSwitchManager>,
|
|
||||||
app_handle: Option<tauri::AppHandle>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut client_builder = Client::builder();
|
let mut client_builder = Client::builder();
|
||||||
if timeout_secs > 0 {
|
if timeout_secs > 0 {
|
||||||
@@ -52,132 +41,35 @@ impl RequestForwarder {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
router,
|
router: ProviderRouter::new(db),
|
||||||
max_retries,
|
max_retries,
|
||||||
status,
|
status,
|
||||||
current_providers,
|
|
||||||
failover_manager,
|
|
||||||
app_handle,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对单个 Provider 执行请求(带重试)
|
/// 转发请求(带重试和故障转移)
|
||||||
///
|
|
||||||
/// 在同一个 Provider 上最多重试 max_retries 次,使用指数退避
|
|
||||||
async fn forward_with_provider_retry(
|
|
||||||
&self,
|
|
||||||
provider: &Provider,
|
|
||||||
endpoint: &str,
|
|
||||||
body: &Value,
|
|
||||||
headers: &axum::http::HeaderMap,
|
|
||||||
adapter: &dyn ProviderAdapter,
|
|
||||||
) -> Result<Response, ProxyError> {
|
|
||||||
let mut last_error = None;
|
|
||||||
|
|
||||||
for attempt in 0..=self.max_retries {
|
|
||||||
if attempt > 0 {
|
|
||||||
// 指数退避:100ms, 200ms, 400ms, ...
|
|
||||||
let delay_ms = 100 * 2u64.pow(attempt as u32 - 1);
|
|
||||||
log::info!(
|
|
||||||
"[{}] 重试第 {}/{} 次(等待 {}ms)",
|
|
||||||
adapter.name(),
|
|
||||||
attempt,
|
|
||||||
self.max_retries,
|
|
||||||
delay_ms
|
|
||||||
);
|
|
||||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self
|
|
||||||
.forward(provider, endpoint, body, headers, adapter)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(response) => return Ok(response),
|
|
||||||
Err(e) => {
|
|
||||||
let category = self.categorize_proxy_error(&e);
|
|
||||||
|
|
||||||
// 只有可重试的错误才继续重试
|
|
||||||
if category == ErrorCategory::NonRetryable {
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"[{}] Provider {} 第 {} 次请求失败: {}",
|
|
||||||
adapter.name(),
|
|
||||||
provider.name,
|
|
||||||
attempt + 1,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
last_error = Some(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转发请求(带故障转移)
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `app_type` - 应用类型
|
|
||||||
/// * `endpoint` - API 端点
|
|
||||||
/// * `body` - 请求体
|
|
||||||
/// * `headers` - 请求头
|
|
||||||
/// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers)
|
|
||||||
pub async fn forward_with_retry(
|
pub async fn forward_with_retry(
|
||||||
&self,
|
&self,
|
||||||
app_type: &AppType,
|
app_type: &AppType,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
body: Value,
|
body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
|
||||||
) -> Result<Response, ProxyError> {
|
) -> Result<Response, ProxyError> {
|
||||||
|
let mut failed_ids = Vec::new();
|
||||||
|
let mut failover_happened = false;
|
||||||
|
|
||||||
// 获取适配器
|
// 获取适配器
|
||||||
let adapter = get_adapter(app_type);
|
let adapter = get_adapter(app_type);
|
||||||
let app_type_str = app_type.as_str();
|
|
||||||
|
|
||||||
if providers.is_empty() {
|
for attempt in 0..self.max_retries {
|
||||||
return Err(ProxyError::NoAvailableProvider);
|
// 选择Provider
|
||||||
}
|
let provider = self.router.select_provider(app_type, &failed_ids).await?;
|
||||||
|
|
||||||
log::info!(
|
log::debug!(
|
||||||
"[{}] 故障转移链: {} 个可用供应商",
|
"尝试 {} - 使用Provider: {} ({})",
|
||||||
app_type_str,
|
attempt + 1,
|
||||||
providers.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut last_error = None;
|
|
||||||
let mut failover_happened = false;
|
|
||||||
let mut attempted_providers = 0usize;
|
|
||||||
|
|
||||||
// 依次尝试每个供应商
|
|
||||||
for provider in providers.iter() {
|
|
||||||
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
|
|
||||||
if !self
|
|
||||||
.router
|
|
||||||
.allow_provider_request(&provider.id, app_type_str)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::debug!(
|
|
||||||
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
|
|
||||||
app_type_str,
|
|
||||||
provider.name
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
attempted_providers += 1;
|
|
||||||
if attempted_providers > 1 {
|
|
||||||
failover_happened = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
|
|
||||||
app_type_str,
|
|
||||||
attempted_providers,
|
|
||||||
providers.len(),
|
|
||||||
provider.name,
|
provider.name,
|
||||||
provider.sort_index.unwrap_or(999999)
|
provider.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// 更新状态中的当前Provider信息
|
// 更新状态中的当前Provider信息
|
||||||
@@ -187,35 +79,25 @@ impl RequestForwarder {
|
|||||||
status.current_provider_id = Some(provider.id.clone());
|
status.current_provider_id = Some(provider.id.clone());
|
||||||
status.total_requests += 1;
|
status.total_requests += 1;
|
||||||
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
if attempt > 0 {
|
||||||
|
failover_happened = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
// 转发请求(带单 Provider 内重试)
|
// 转发请求
|
||||||
match self
|
match self
|
||||||
.forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref())
|
.forward(&provider, endpoint, &body, &headers, adapter.as_ref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let latency = start.elapsed().as_millis() as u64;
|
let _latency = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
// 成功:记录成功并更新熔断器
|
// 成功:更新健康状态
|
||||||
if let Err(e) = self
|
self.router
|
||||||
.router
|
.update_health(&provider, app_type, true, None)
|
||||||
.record_result(&provider.id, app_type_str, true, None)
|
.await;
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!("Failed to record success: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新当前应用类型使用的 provider
|
|
||||||
{
|
|
||||||
let mut current_providers = self.current_providers.write().await;
|
|
||||||
current_providers.insert(
|
|
||||||
app_type_str.to_string(),
|
|
||||||
(provider.id.clone(), provider.name.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新成功统计
|
// 更新成功统计
|
||||||
{
|
{
|
||||||
@@ -224,26 +106,6 @@ impl RequestForwarder {
|
|||||||
status.last_error = None;
|
status.last_error = None;
|
||||||
if failover_happened {
|
if failover_happened {
|
||||||
status.failover_count += 1;
|
status.failover_count += 1;
|
||||||
log::info!(
|
|
||||||
"[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)",
|
|
||||||
app_type_str,
|
|
||||||
provider.name,
|
|
||||||
latency
|
|
||||||
);
|
|
||||||
|
|
||||||
// 异步触发供应商切换,更新 UI 和托盘菜单
|
|
||||||
let fm = self.failover_manager.clone();
|
|
||||||
let ah = self.app_handle.clone();
|
|
||||||
let pid = provider.id.clone();
|
|
||||||
let pname = provider.name.clone();
|
|
||||||
let at = app_type_str.to_string();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = fm.try_switch(ah.as_ref(), &at, &pid, &pname).await
|
|
||||||
{
|
|
||||||
log::error!("[Failover] 切换供应商失败: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// 重新计算成功率
|
// 重新计算成功率
|
||||||
if status.total_requests > 0 {
|
if status.total_requests > 0 {
|
||||||
@@ -253,33 +115,23 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] 请求成功 - Provider: {} - {}ms",
|
|
||||||
app_type_str,
|
|
||||||
provider.name,
|
|
||||||
latency
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let latency = start.elapsed().as_millis() as u64;
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
// 失败:记录失败并更新熔断器
|
// 失败:分类错误
|
||||||
if let Err(record_err) = self
|
|
||||||
.router
|
|
||||||
.record_result(&provider.id, app_type_str, false, Some(e.to_string()))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!("Failed to record failure: {record_err}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分类错误
|
|
||||||
let category = self.categorize_proxy_error(&e);
|
let category = self.categorize_proxy_error(&e);
|
||||||
|
|
||||||
match category {
|
match category {
|
||||||
ErrorCategory::Retryable => {
|
ErrorCategory::Retryable => {
|
||||||
// 可重试:更新错误信息,继续尝试下一个供应商
|
// 可重试:更新健康状态,添加到失败列表
|
||||||
|
self.router
|
||||||
|
.update_health(&provider, app_type, false, Some(e.to_string()))
|
||||||
|
.await;
|
||||||
|
failed_ids.push(provider.id.clone());
|
||||||
|
|
||||||
|
// 更新错误信息
|
||||||
{
|
{
|
||||||
let mut status = self.status.write().await;
|
let mut status = self.status.write().await;
|
||||||
status.last_error =
|
status.last_error =
|
||||||
@@ -287,19 +139,15 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[{}] Provider {} 失败(可重试): {} - {}ms",
|
"请求失败(可重试): Provider {} - {} - {}ms",
|
||||||
app_type_str,
|
|
||||||
provider.name,
|
provider.name,
|
||||||
e,
|
e,
|
||||||
latency
|
latency
|
||||||
);
|
);
|
||||||
|
|
||||||
last_error = Some(e);
|
|
||||||
// 继续尝试下一个供应商
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
|
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
|
||||||
// 不可重试:直接返回错误
|
// 不可重试:更新失败统计并返回
|
||||||
{
|
{
|
||||||
let mut status = self.status.write().await;
|
let mut status = self.status.write().await;
|
||||||
status.failed_requests += 1;
|
status.failed_requests += 1;
|
||||||
@@ -310,12 +158,7 @@ impl RequestForwarder {
|
|||||||
* 100.0;
|
* 100.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log::error!(
|
log::error!("请求失败(不可重试): {e}");
|
||||||
"[{}] Provider {} 失败(不可重试): {}",
|
|
||||||
app_type_str,
|
|
||||||
provider.name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,38 +166,18 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if attempted_providers == 0 {
|
// 所有重试都失败
|
||||||
// providers 列表非空,但全部被熔断器拒绝(典型:HalfOpen 探测名额被占用)
|
|
||||||
{
|
|
||||||
let mut status = self.status.write().await;
|
|
||||||
status.failed_requests += 1;
|
|
||||||
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
|
|
||||||
if status.total_requests > 0 {
|
|
||||||
status.success_rate =
|
|
||||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Err(ProxyError::NoAvailableProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 所有供应商都失败了
|
|
||||||
{
|
{
|
||||||
let mut status = self.status.write().await;
|
let mut status = self.status.write().await;
|
||||||
status.failed_requests += 1;
|
status.failed_requests += 1;
|
||||||
status.last_error = Some("所有供应商都失败".to_string());
|
status.last_error = Some("已达到最大重试次数".to_string());
|
||||||
if status.total_requests > 0 {
|
if status.total_requests > 0 {
|
||||||
status.success_rate =
|
status.success_rate =
|
||||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::error!(
|
Err(ProxyError::MaxRetriesExceeded)
|
||||||
"[{}] 所有 {} 个供应商都失败了",
|
|
||||||
app_type_str,
|
|
||||||
providers.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转发单个请求(使用适配器)
|
/// 转发单个请求(使用适配器)
|
||||||
@@ -484,24 +307,21 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 分类ProxyError
|
/// 分类ProxyError
|
||||||
///
|
|
||||||
/// 决定哪些错误应该触发故障转移到下一个 Provider
|
|
||||||
///
|
|
||||||
/// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。
|
|
||||||
/// 只有明确是客户端中断的情况才不重试。
|
|
||||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||||
match error {
|
match error {
|
||||||
// 网络和上游错误:都应该尝试下一个供应商
|
|
||||||
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
||||||
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
|
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
|
||||||
|
ProxyError::UpstreamError { status, .. } => {
|
||||||
|
if *status >= 500 {
|
||||||
|
ErrorCategory::Retryable
|
||||||
|
} else if *status >= 400 && *status < 500 {
|
||||||
|
ErrorCategory::NonRetryable
|
||||||
|
} else {
|
||||||
|
ErrorCategory::Retryable
|
||||||
|
}
|
||||||
|
}
|
||||||
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
|
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
|
||||||
// 上游 HTTP 错误:无论状态码如何,都尝试下一个供应商
|
|
||||||
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
|
|
||||||
// 不代表其他供应商也会失败
|
|
||||||
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,
|
|
||||||
// 无可用供应商:所有供应商都试过了,无法重试
|
|
||||||
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
|
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
|
||||||
// 其他错误(配置错误、数据库错误等):不是供应商问题,无需重试
|
|
||||||
_ => ErrorCategory::NonRetryable,
|
_ => ErrorCategory::NonRetryable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
//! Handler 配置模块
|
|
||||||
//!
|
|
||||||
//! 定义各 API 处理器的配置结构和使用量解析器
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::proxy::usage::parser::TokenUsage;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// 使用量解析器类型别名
|
|
||||||
pub type StreamUsageParser = fn(&[Value]) -> Option<TokenUsage>;
|
|
||||||
pub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;
|
|
||||||
|
|
||||||
/// 模型提取器类型别名
|
|
||||||
/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称
|
|
||||||
pub type StreamModelExtractor = fn(&[Value], &str) -> String;
|
|
||||||
|
|
||||||
/// 各 API 的使用量解析配置
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub struct UsageParserConfig {
|
|
||||||
/// 流式响应解析器
|
|
||||||
pub stream_parser: StreamUsageParser,
|
|
||||||
/// 非流式响应解析器
|
|
||||||
pub response_parser: ResponseUsageParser,
|
|
||||||
/// 流式响应中的模型提取器
|
|
||||||
pub model_extractor: StreamModelExtractor,
|
|
||||||
/// 应用类型字符串(用于日志记录)
|
|
||||||
pub app_type_str: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 模型提取器实现
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Claude 流式响应模型提取(直接使用请求模型)
|
|
||||||
fn claude_model_extractor(_events: &[Value], request_model: &str) -> String {
|
|
||||||
request_model.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OpenAI Chat Completions 流式响应模型提取
|
|
||||||
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.find_map(|e| e.get("model")?.as_str())
|
|
||||||
.unwrap_or(request_model)
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Codex Responses API 流式响应模型提取
|
|
||||||
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
|
||||||
events
|
|
||||||
.iter()
|
|
||||||
.find_map(|e| {
|
|
||||||
if e.get("type")?.as_str()? == "response.completed" {
|
|
||||||
e.get("response")?.get("model")?.as_str()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or(request_model)
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gemini 流式响应模型提取(优先使用 usage.model)
|
|
||||||
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
|
|
||||||
// 首先尝试从解析的 usage 中获取模型
|
|
||||||
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
|
|
||||||
if let Some(model) = usage.model {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
request_model.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 预定义配置
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Claude API 解析配置
|
|
||||||
pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
|
||||||
stream_parser: TokenUsage::from_claude_stream_events,
|
|
||||||
response_parser: TokenUsage::from_claude_response,
|
|
||||||
model_extractor: claude_model_extractor,
|
|
||||||
app_type_str: "claude",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// OpenAI Chat Completions API 解析配置(用于 Codex /v1/chat/completions)
|
|
||||||
pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
|
||||||
stream_parser: TokenUsage::from_openai_stream_events,
|
|
||||||
response_parser: TokenUsage::from_openai_response,
|
|
||||||
model_extractor: openai_model_extractor,
|
|
||||||
app_type_str: "codex",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Codex Responses API 解析配置(用于 /v1/responses)
|
|
||||||
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
|
||||||
stream_parser: TokenUsage::from_codex_stream_events,
|
|
||||||
response_parser: TokenUsage::from_codex_response,
|
|
||||||
model_extractor: codex_model_extractor,
|
|
||||||
app_type_str: "codex",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Gemini API 解析配置
|
|
||||||
pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
|
||||||
stream_parser: TokenUsage::from_gemini_stream_chunks,
|
|
||||||
response_parser: TokenUsage::from_gemini_response,
|
|
||||||
model_extractor: gemini_model_extractor,
|
|
||||||
app_type_str: "gemini",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Handler 配置(预留,用于进一步简化)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Handler 基础配置
|
|
||||||
///
|
|
||||||
/// 预留结构,可用于进一步统一各 handler 的配置
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct HandlerConfig {
|
|
||||||
/// 应用类型
|
|
||||||
pub app_type: AppType,
|
|
||||||
/// 日志标签
|
|
||||||
pub tag: &'static str,
|
|
||||||
/// 应用类型字符串
|
|
||||||
pub app_type_str: &'static str,
|
|
||||||
/// 使用量解析配置
|
|
||||||
pub parser_config: &'static UsageParserConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Claude Handler 配置
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub const CLAUDE_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
|
||||||
app_type: AppType::Claude,
|
|
||||||
tag: "Claude",
|
|
||||||
app_type_str: "claude",
|
|
||||||
parser_config: &CLAUDE_PARSER_CONFIG,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Codex Chat Completions Handler 配置
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub const CODEX_CHAT_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
|
||||||
app_type: AppType::Codex,
|
|
||||||
tag: "Codex",
|
|
||||||
app_type_str: "codex",
|
|
||||||
parser_config: &OPENAI_PARSER_CONFIG,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Codex Responses Handler 配置
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub const CODEX_RESPONSES_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
|
||||||
app_type: AppType::Codex,
|
|
||||||
tag: "Codex",
|
|
||||||
app_type_str: "codex",
|
|
||||||
parser_config: &CODEX_PARSER_CONFIG,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Gemini Handler 配置
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub const GEMINI_HANDLER_CONFIG: HandlerConfig = HandlerConfig {
|
|
||||||
app_type: AppType::Gemini,
|
|
||||||
tag: "Gemini",
|
|
||||||
app_type_str: "gemini",
|
|
||||||
parser_config: &GEMINI_PARSER_CONFIG,
|
|
||||||
};
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
//! 请求上下文模块
|
|
||||||
//!
|
|
||||||
//! 提供请求生命周期的上下文管理,封装通用初始化逻辑
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::proxy::{
|
|
||||||
forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError,
|
|
||||||
};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
/// 请求上下文
|
|
||||||
///
|
|
||||||
/// 贯穿整个请求生命周期,包含:
|
|
||||||
/// - 计时信息
|
|
||||||
/// - 代理配置
|
|
||||||
/// - 选中的 Provider 列表(用于故障转移)
|
|
||||||
/// - 请求模型名称
|
|
||||||
/// - 日志标签
|
|
||||||
pub struct RequestContext {
|
|
||||||
/// 请求开始时间
|
|
||||||
pub start_time: Instant,
|
|
||||||
/// 代理配置快照
|
|
||||||
pub config: ProxyConfig,
|
|
||||||
/// 选中的 Provider(故障转移链的第一个)
|
|
||||||
pub provider: Provider,
|
|
||||||
/// 完整的 Provider 列表(用于故障转移)
|
|
||||||
providers: Vec<Provider>,
|
|
||||||
/// 请求中的模型名称
|
|
||||||
pub request_model: String,
|
|
||||||
/// 日志标签(如 "Claude"、"Codex"、"Gemini")
|
|
||||||
pub tag: &'static str,
|
|
||||||
/// 应用类型字符串(如 "claude"、"codex"、"gemini")
|
|
||||||
pub app_type_str: &'static str,
|
|
||||||
/// 应用类型(预留,目前通过 app_type_str 使用)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub app_type: AppType,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RequestContext {
|
|
||||||
/// 创建请求上下文
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `state` - 代理服务器状态
|
|
||||||
/// * `body` - 请求体 JSON
|
|
||||||
/// * `app_type` - 应用类型
|
|
||||||
/// * `tag` - 日志标签
|
|
||||||
/// * `app_type_str` - 应用类型字符串
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// 返回 `ProxyError` 如果 Provider 选择失败
|
|
||||||
pub async fn new(
|
|
||||||
state: &ProxyState,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
app_type: AppType,
|
|
||||||
tag: &'static str,
|
|
||||||
app_type_str: &'static str,
|
|
||||||
) -> Result<Self, ProxyError> {
|
|
||||||
let start_time = Instant::now();
|
|
||||||
let config = state.config.read().await.clone();
|
|
||||||
|
|
||||||
// 从请求体提取模型名称
|
|
||||||
let request_model = body
|
|
||||||
.get("model")
|
|
||||||
.and_then(|m| m.as_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持)
|
|
||||||
// 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额
|
|
||||||
let providers = state
|
|
||||||
.provider_router
|
|
||||||
.select_providers(app_type_str)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
|
||||||
|
|
||||||
let provider = providers
|
|
||||||
.first()
|
|
||||||
.cloned()
|
|
||||||
.ok_or(ProxyError::NoAvailableProvider)?;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] Provider: {}, model: {}, failover chain: {} providers",
|
|
||||||
tag,
|
|
||||||
provider.name,
|
|
||||||
request_model,
|
|
||||||
providers.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
start_time,
|
|
||||||
config,
|
|
||||||
provider,
|
|
||||||
providers,
|
|
||||||
request_model,
|
|
||||||
tag,
|
|
||||||
app_type_str,
|
|
||||||
app_type,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 URI 提取模型名称(Gemini 专用)
|
|
||||||
///
|
|
||||||
/// Gemini API 的模型名称在 URI 中,格式如:
|
|
||||||
/// `/v1beta/models/gemini-pro:generateContent`
|
|
||||||
pub fn with_model_from_uri(mut self, uri: &axum::http::Uri) -> Self {
|
|
||||||
let endpoint = uri
|
|
||||||
.path_and_query()
|
|
||||||
.map(|pq| pq.as_str())
|
|
||||||
.unwrap_or(uri.path());
|
|
||||||
|
|
||||||
self.request_model = endpoint
|
|
||||||
.split('/')
|
|
||||||
.find(|s| s.starts_with("models/"))
|
|
||||||
.and_then(|s| s.strip_prefix("models/"))
|
|
||||||
.map(|s| s.split(':').next().unwrap_or(s))
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建 RequestForwarder
|
|
||||||
///
|
|
||||||
/// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持
|
|
||||||
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
|
|
||||||
RequestForwarder::new(
|
|
||||||
state.provider_router.clone(),
|
|
||||||
self.config.request_timeout,
|
|
||||||
self.config.max_retries,
|
|
||||||
state.status.clone(),
|
|
||||||
state.current_providers.clone(),
|
|
||||||
state.failover_manager.clone(),
|
|
||||||
state.app_handle.clone(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取 Provider 列表(用于故障转移)
|
|
||||||
///
|
|
||||||
/// 返回在创建上下文时已选择的 providers,避免重复调用 select_providers()
|
|
||||||
pub fn get_providers(&self) -> Vec<Provider> {
|
|
||||||
self.providers.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 计算请求延迟(毫秒)
|
|
||||||
#[inline]
|
|
||||||
pub fn latency_ms(&self) -> u64 {
|
|
||||||
self.start_time.elapsed().as_millis() as u64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1053
-434
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||||
|
|
||||||
pub mod circuit_breaker;
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod error_mapper;
|
|
||||||
pub(crate) mod failover_switch;
|
|
||||||
mod forwarder;
|
mod forwarder;
|
||||||
pub mod handler_config;
|
|
||||||
pub mod handler_context;
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod health;
|
mod health;
|
||||||
pub mod provider_router;
|
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod response_handler;
|
pub mod response_handler;
|
||||||
pub mod response_processor;
|
mod router;
|
||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
@@ -22,14 +16,8 @@ pub mod usage;
|
|||||||
|
|
||||||
// 公开导出给外部使用(commands, services等模块需要)
|
// 公开导出给外部使用(commands, services等模块需要)
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use circuit_breaker::{
|
|
||||||
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
|
|
||||||
};
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use error::ProxyError;
|
pub use error::ProxyError;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use provider_router::ProviderRouter;
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use session::{ClientFormat, ProxySession};
|
pub use session::{ClientFormat, ProxySession};
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
//! 供应商路由器模块
|
|
||||||
//!
|
|
||||||
//! 负责选择和管理代理目标供应商,实现智能故障转移
|
|
||||||
|
|
||||||
use crate::database::Database;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::proxy::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
/// 供应商路由器
|
|
||||||
pub struct ProviderRouter {
|
|
||||||
/// 数据库连接
|
|
||||||
db: Arc<Database>,
|
|
||||||
/// 熔断器管理器 - key 格式: "app_type:provider_id"
|
|
||||||
circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderRouter {
|
|
||||||
/// 创建新的供应商路由器
|
|
||||||
pub fn new(db: Arc<Database>) -> Self {
|
|
||||||
Self {
|
|
||||||
db,
|
|
||||||
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 选择可用的供应商(支持故障转移)
|
|
||||||
///
|
|
||||||
/// 返回按优先级排序的可用供应商列表:
|
|
||||||
/// 1. 当前供应商(is_current=true)始终第一位
|
|
||||||
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序)
|
|
||||||
/// 3. 只返回熔断器未打开的供应商
|
|
||||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
let all_providers = self.db.get_all_providers(app_type)?;
|
|
||||||
|
|
||||||
// 1. 当前供应商始终第一位
|
|
||||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
|
||||||
if let Some(current) = all_providers.get(¤t_id) {
|
|
||||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 获取故障转移队列中的供应商
|
|
||||||
let queue = self.db.get_failover_queue(app_type)?;
|
|
||||||
|
|
||||||
for item in queue {
|
|
||||||
// 跳过已添加的当前供应商
|
|
||||||
if result.iter().any(|p| p.id == item.provider_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳过禁用的队列项
|
|
||||||
if !item.enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取供应商信息
|
|
||||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
|
||||||
// 检查熔断器状态
|
|
||||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
|
||||||
|
|
||||||
if breaker.is_available().await {
|
|
||||||
log::info!(
|
|
||||||
"[{}] Failover provider available: {} ({}) at queue position {}",
|
|
||||||
app_type,
|
|
||||||
provider.name,
|
|
||||||
provider.id,
|
|
||||||
item.queue_order
|
|
||||||
);
|
|
||||||
result.push(provider.clone());
|
|
||||||
} else {
|
|
||||||
log::debug!(
|
|
||||||
"[{}] Failover provider {} circuit breaker open, skipping",
|
|
||||||
app_type,
|
|
||||||
provider.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.is_empty() {
|
|
||||||
return Err(AppError::Config(format!(
|
|
||||||
"No available provider for {app_type} (all circuit breakers open or no providers configured)"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[{}] Failover chain: {} provider(s) available",
|
|
||||||
app_type,
|
|
||||||
result.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 请求执行前获取熔断器“放行许可”
|
|
||||||
///
|
|
||||||
/// - Closed:直接放行
|
|
||||||
/// - Open:超时到达后切到 HalfOpen 并放行一次探测
|
|
||||||
/// - HalfOpen:按限流规则放行探测
|
|
||||||
///
|
|
||||||
/// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额,
|
|
||||||
/// 否则会导致该 Provider 长时间无法进入探测状态。
|
|
||||||
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> bool {
|
|
||||||
let circuit_key = format!("{app_type}:{provider_id}");
|
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
|
||||||
breaker.allow_request().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 记录供应商请求结果
|
|
||||||
pub async fn record_result(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
success: bool,
|
|
||||||
error_msg: Option<String>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
// 1. 获取熔断器配置(用于更新健康状态和判断是否禁用)
|
|
||||||
let config = self.db.get_circuit_breaker_config().await.ok();
|
|
||||||
let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5);
|
|
||||||
|
|
||||||
// 2. 更新熔断器状态
|
|
||||||
let circuit_key = format!("{app_type}:{provider_id}");
|
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
|
||||||
|
|
||||||
if success {
|
|
||||||
breaker.record_success().await;
|
|
||||||
log::debug!("Provider {provider_id} request succeeded");
|
|
||||||
} else {
|
|
||||||
breaker.record_failure().await;
|
|
||||||
log::warn!(
|
|
||||||
"Provider {} request failed: {}",
|
|
||||||
provider_id,
|
|
||||||
error_msg.as_deref().unwrap_or("Unknown error")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 更新数据库健康状态(使用配置的阈值)
|
|
||||||
self.db
|
|
||||||
.update_provider_health_with_threshold(
|
|
||||||
provider_id,
|
|
||||||
app_type,
|
|
||||||
success,
|
|
||||||
error_msg.clone(),
|
|
||||||
failure_threshold,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置熔断器(手动恢复)
|
|
||||||
pub async fn reset_circuit_breaker(&self, circuit_key: &str) {
|
|
||||||
let breakers = self.circuit_breakers.read().await;
|
|
||||||
if let Some(breaker) = breakers.get(circuit_key) {
|
|
||||||
log::info!("Manually resetting circuit breaker for {circuit_key}");
|
|
||||||
breaker.reset().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置指定供应商的熔断器
|
|
||||||
pub async fn reset_provider_breaker(&self, provider_id: &str, app_type: &str) {
|
|
||||||
let circuit_key = format!("{app_type}:{provider_id}");
|
|
||||||
self.reset_circuit_breaker(&circuit_key).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新所有熔断器的配置(热更新)
|
|
||||||
///
|
|
||||||
/// 当用户在 UI 中修改熔断器配置后调用此方法,
|
|
||||||
/// 所有现有的熔断器会立即使用新配置
|
|
||||||
pub async fn update_all_configs(&self, config: CircuitBreakerConfig) {
|
|
||||||
let breakers = self.circuit_breakers.read().await;
|
|
||||||
let count = breakers.len();
|
|
||||||
|
|
||||||
for breaker in breakers.values() {
|
|
||||||
breaker.update_config(config.clone()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("已更新 {count} 个熔断器的配置");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取熔断器状态
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn get_circuit_breaker_stats(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Option<crate::proxy::circuit_breaker::CircuitBreakerStats> {
|
|
||||||
let circuit_key = format!("{app_type}:{provider_id}");
|
|
||||||
let breakers = self.circuit_breakers.read().await;
|
|
||||||
|
|
||||||
if let Some(breaker) = breakers.get(&circuit_key) {
|
|
||||||
Some(breaker.get_stats().await)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取或创建熔断器
|
|
||||||
async fn get_or_create_circuit_breaker(&self, key: &str) -> Arc<CircuitBreaker> {
|
|
||||||
// 先尝试读锁获取
|
|
||||||
{
|
|
||||||
let breakers = self.circuit_breakers.read().await;
|
|
||||||
if let Some(breaker) = breakers.get(key) {
|
|
||||||
return breaker.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果不存在,获取写锁创建
|
|
||||||
let mut breakers = self.circuit_breakers.write().await;
|
|
||||||
|
|
||||||
// 双重检查,防止竞争条件
|
|
||||||
if let Some(breaker) = breakers.get(key) {
|
|
||||||
return breaker.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从数据库加载配置
|
|
||||||
let config = self
|
|
||||||
.db
|
|
||||||
.get_circuit_breaker_config()
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
|
||||||
|
|
||||||
let breaker = Arc::new(CircuitBreaker::new(config));
|
|
||||||
breakers.insert(key.to_string(), breaker.clone());
|
|
||||||
|
|
||||||
breaker
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::database::Database;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_provider_router_creation() {
|
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
|
||||||
let router = ProviderRouter::new(db);
|
|
||||||
|
|
||||||
// 测试创建熔断器
|
|
||||||
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
|
||||||
assert!(breaker.allow_request().await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn select_providers_does_not_consume_half_open_permit() {
|
|
||||||
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 =
|
|
||||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
|
||||||
let provider_b =
|
|
||||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
|
||||||
|
|
||||||
db.save_provider("claude", &provider_a).unwrap();
|
|
||||||
db.save_provider("claude", &provider_b).unwrap();
|
|
||||||
db.set_current_provider("claude", "a").unwrap();
|
|
||||||
db.add_to_failover_queue("claude", "b").unwrap();
|
|
||||||
|
|
||||||
let router = ProviderRouter::new(db.clone());
|
|
||||||
|
|
||||||
// 让 B 进入 Open 状态(failure_threshold=1)
|
|
||||||
router
|
|
||||||
.record_result("b", "claude", false, Some("fail".to_string()))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
|
|
||||||
let providers = router.select_providers("claude").await.unwrap();
|
|
||||||
assert_eq!(providers.len(), 2);
|
|
||||||
|
|
||||||
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
|
|
||||||
assert!(router.allow_provider_request("b", "claude").await);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -253,6 +253,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
is_proxy_target: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
is_proxy_target: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ impl GeminiAdapter {
|
|||||||
/// 从 Provider 配置中提取原始 API Key
|
/// 从 Provider 配置中提取原始 API Key
|
||||||
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
||||||
if let Some(env) = provider.settings_config.get("env") {
|
if let Some(env) = provider.settings_config.get("env") {
|
||||||
// 使用 GEMINI_API_KEY
|
// 优先使用 GOOGLE_GEMINI_API_KEY
|
||||||
|
if let Some(key) = env.get("GOOGLE_GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
|
// 备选 GEMINI_API_KEY
|
||||||
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||||
return Some(key.to_string());
|
return Some(key.to_string());
|
||||||
}
|
}
|
||||||
@@ -250,6 +254,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
is_proxy_target: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +276,7 @@ mod tests {
|
|||||||
let adapter = GeminiAdapter::new();
|
let adapter = GeminiAdapter::new();
|
||||||
let provider = create_provider(json!({
|
let provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "AIza-test-key-12345678"
|
"GOOGLE_GEMINI_API_KEY": "AIza-test-key-12345678"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -286,7 +291,7 @@ mod tests {
|
|||||||
let adapter = GeminiAdapter::new();
|
let adapter = GeminiAdapter::new();
|
||||||
let provider = create_provider(json!({
|
let provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "ya29.test-access-token-12345"
|
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token-12345"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -303,7 +308,7 @@ mod tests {
|
|||||||
let adapter = GeminiAdapter::new();
|
let adapter = GeminiAdapter::new();
|
||||||
let provider = create_provider(json!({
|
let provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
|
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -319,7 +324,7 @@ mod tests {
|
|||||||
// API Key
|
// API Key
|
||||||
let api_key_provider = create_provider(json!({
|
let api_key_provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "AIza-test-key"
|
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -330,7 +335,7 @@ mod tests {
|
|||||||
// OAuth access_token
|
// OAuth access_token
|
||||||
let oauth_provider = create_provider(json!({
|
let oauth_provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "ya29.test-token"
|
"GOOGLE_GEMINI_API_KEY": "ya29.test-token"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -341,7 +346,7 @@ mod tests {
|
|||||||
// OAuth JSON
|
// OAuth JSON
|
||||||
let oauth_json_provider = create_provider(json!({
|
let oauth_json_provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
|
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
is_proxy_target: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +369,7 @@ mod tests {
|
|||||||
fn test_from_app_type_gemini_api_key() {
|
fn test_from_app_type_gemini_api_key() {
|
||||||
let provider = create_provider(json!({
|
let provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "AIza-test-key"
|
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -380,7 +381,7 @@ mod tests {
|
|||||||
fn test_from_app_type_gemini_cli_oauth() {
|
fn test_from_app_type_gemini_cli_oauth() {
|
||||||
let provider = create_provider(json!({
|
let provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "ya29.test-access-token"
|
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -392,7 +393,7 @@ mod tests {
|
|||||||
fn test_from_app_type_gemini_cli_json() {
|
fn test_from_app_type_gemini_cli_json() {
|
||||||
let provider = create_provider(json!({
|
let provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
|
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
is_proxy_target: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,411 +0,0 @@
|
|||||||
//! 响应处理器模块
|
|
||||||
//!
|
|
||||||
//! 统一处理流式和非流式 API 响应
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState,
|
|
||||||
usage::parser::TokenUsage, ProxyError,
|
|
||||||
};
|
|
||||||
use axum::response::Response;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures::stream::{Stream, StreamExt};
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::{
|
|
||||||
str::FromStr,
|
|
||||||
sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 公共接口
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 检测响应是否为 SSE 流式响应
|
|
||||||
#[inline]
|
|
||||||
pub fn is_sse_response(response: &reqwest::Response) -> bool {
|
|
||||||
response
|
|
||||||
.headers()
|
|
||||||
.get("content-type")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|ct| ct.contains("text/event-stream"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理流式响应
|
|
||||||
pub async fn handle_streaming(
|
|
||||||
response: reqwest::Response,
|
|
||||||
ctx: &RequestContext,
|
|
||||||
state: &ProxyState,
|
|
||||||
parser_config: &UsageParserConfig,
|
|
||||||
) -> Response {
|
|
||||||
log::info!("[{}] 流式透传响应 (SSE)", ctx.tag);
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
let mut builder = axum::response::Response::builder().status(status);
|
|
||||||
|
|
||||||
// 复制响应头
|
|
||||||
for (key, value) in response.headers() {
|
|
||||||
builder = builder.header(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建字节流
|
|
||||||
let stream = response
|
|
||||||
.bytes_stream()
|
|
||||||
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
|
|
||||||
|
|
||||||
// 创建使用量收集器
|
|
||||||
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 body = axum::body::Body::from_stream(logged_stream);
|
|
||||||
builder.body(body).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理非流式响应
|
|
||||||
pub async fn handle_non_streaming(
|
|
||||||
response: reqwest::Response,
|
|
||||||
ctx: &RequestContext,
|
|
||||||
state: &ProxyState,
|
|
||||||
parser_config: &UsageParserConfig,
|
|
||||||
) -> Result<Response, ProxyError> {
|
|
||||||
let response_headers = response.headers().clone();
|
|
||||||
let status = response.status();
|
|
||||||
|
|
||||||
// 读取响应体
|
|
||||||
let body_bytes = response.bytes().await.map_err(|e| {
|
|
||||||
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
|
||||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 解析并记录使用量
|
|
||||||
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
|
||||||
log::info!(
|
|
||||||
"[{}] <<< 响应 JSON:\n{}",
|
|
||||||
ctx.tag,
|
|
||||||
serde_json::to_string_pretty(&json_value).unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 解析使用量
|
|
||||||
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
|
||||||
let model = json_value
|
|
||||||
.get("model")
|
|
||||||
.and_then(|m| m.as_str())
|
|
||||||
.unwrap_or(&ctx.request_model);
|
|
||||||
|
|
||||||
spawn_log_usage(state, ctx, usage, model, status.as_u16(), false);
|
|
||||||
} else {
|
|
||||||
log::debug!(
|
|
||||||
"[{}] 未能解析 usage 信息,跳过记录",
|
|
||||||
parser_config.app_type_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"[{}] <<< 响应 (非 JSON): {} bytes",
|
|
||||||
ctx.tag,
|
|
||||||
body_bytes.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
|
|
||||||
|
|
||||||
// 构建响应
|
|
||||||
let mut builder = axum::response::Response::builder().status(status);
|
|
||||||
for (key, value) in response_headers.iter() {
|
|
||||||
builder = builder.header(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = axum::body::Body::from(body_bytes);
|
|
||||||
Ok(builder.body(body).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通用响应处理入口
|
|
||||||
///
|
|
||||||
/// 根据响应类型自动选择流式或非流式处理
|
|
||||||
pub async fn process_response(
|
|
||||||
response: reqwest::Response,
|
|
||||||
ctx: &RequestContext,
|
|
||||||
state: &ProxyState,
|
|
||||||
parser_config: &UsageParserConfig,
|
|
||||||
) -> Result<Response, ProxyError> {
|
|
||||||
if is_sse_response(&response) {
|
|
||||||
Ok(handle_streaming(response, ctx, state, parser_config).await)
|
|
||||||
} else {
|
|
||||||
handle_non_streaming(response, ctx, state, parser_config).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SSE 使用量收集器
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type UsageCallbackWithTiming = Arc<dyn Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static>;
|
|
||||||
|
|
||||||
/// SSE 使用量收集器
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SseUsageCollector {
|
|
||||||
inner: Arc<SseUsageCollectorInner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SseUsageCollectorInner {
|
|
||||||
events: Mutex<Vec<Value>>,
|
|
||||||
first_event_time: Mutex<Option<std::time::Instant>>,
|
|
||||||
start_time: std::time::Instant,
|
|
||||||
on_complete: UsageCallbackWithTiming,
|
|
||||||
finished: AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SseUsageCollector {
|
|
||||||
/// 创建新的使用量收集器
|
|
||||||
pub fn new(
|
|
||||||
start_time: std::time::Instant,
|
|
||||||
callback: impl Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static,
|
|
||||||
) -> Self {
|
|
||||||
let on_complete: UsageCallbackWithTiming = Arc::new(callback);
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(SseUsageCollectorInner {
|
|
||||||
events: Mutex::new(Vec::new()),
|
|
||||||
first_event_time: Mutex::new(None),
|
|
||||||
start_time,
|
|
||||||
on_complete,
|
|
||||||
finished: AtomicBool::new(false),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 推送 SSE 事件
|
|
||||||
pub async fn push(&self, event: Value) {
|
|
||||||
// 记录首个事件时间
|
|
||||||
{
|
|
||||||
let mut first_time = self.inner.first_event_time.lock().await;
|
|
||||||
if first_time.is_none() {
|
|
||||||
*first_time = Some(std::time::Instant::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut events = self.inner.events.lock().await;
|
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 完成收集并触发回调
|
|
||||||
pub async fn finish(&self) {
|
|
||||||
if self.inner.finished.swap(true, Ordering::SeqCst) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let events = {
|
|
||||||
let mut guard = self.inner.events.lock().await;
|
|
||||||
std::mem::take(&mut *guard)
|
|
||||||
};
|
|
||||||
|
|
||||||
let first_token_ms = {
|
|
||||||
let first_time = self.inner.first_event_time.lock().await;
|
|
||||||
first_time.map(|t| (t - self.inner.start_time).as_millis() as u64)
|
|
||||||
};
|
|
||||||
|
|
||||||
(self.inner.on_complete)(events, first_token_ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 内部辅助函数
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 创建使用量收集器
|
|
||||||
fn create_usage_collector(
|
|
||||||
ctx: &RequestContext,
|
|
||||||
state: &ProxyState,
|
|
||||||
status_code: u16,
|
|
||||||
parser_config: &UsageParserConfig,
|
|
||||||
) -> SseUsageCollector {
|
|
||||||
let state = state.clone();
|
|
||||||
let provider_id = ctx.provider.id.clone();
|
|
||||||
let request_model = ctx.request_model.clone();
|
|
||||||
let app_type_str = parser_config.app_type_str;
|
|
||||||
let tag = ctx.tag;
|
|
||||||
let start_time = ctx.start_time;
|
|
||||||
let stream_parser = parser_config.stream_parser;
|
|
||||||
let model_extractor = parser_config.model_extractor;
|
|
||||||
|
|
||||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
|
||||||
if let Some(usage) = stream_parser(&events) {
|
|
||||||
let model = model_extractor(&events, &request_model);
|
|
||||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
|
||||||
|
|
||||||
let state = state.clone();
|
|
||||||
let provider_id = provider_id.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
log_usage_internal(
|
|
||||||
&state,
|
|
||||||
&provider_id,
|
|
||||||
app_type_str,
|
|
||||||
&model,
|
|
||||||
usage,
|
|
||||||
latency_ms,
|
|
||||||
first_token_ms,
|
|
||||||
true, // is_streaming
|
|
||||||
status_code,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 异步记录使用量
|
|
||||||
fn spawn_log_usage(
|
|
||||||
state: &ProxyState,
|
|
||||||
ctx: &RequestContext,
|
|
||||||
usage: TokenUsage,
|
|
||||||
model: &str,
|
|
||||||
status_code: u16,
|
|
||||||
is_streaming: bool,
|
|
||||||
) {
|
|
||||||
let state = state.clone();
|
|
||||||
let provider_id = ctx.provider.id.clone();
|
|
||||||
let app_type_str = ctx.app_type_str.to_string();
|
|
||||||
let model = model.to_string();
|
|
||||||
let latency_ms = ctx.latency_ms();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
log_usage_internal(
|
|
||||||
&state,
|
|
||||||
&provider_id,
|
|
||||||
&app_type_str,
|
|
||||||
&model,
|
|
||||||
usage,
|
|
||||||
latency_ms,
|
|
||||||
None,
|
|
||||||
is_streaming,
|
|
||||||
status_code,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 内部使用量记录函数
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn log_usage_internal(
|
|
||||||
state: &ProxyState,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
model: &str,
|
|
||||||
usage: TokenUsage,
|
|
||||||
latency_ms: u64,
|
|
||||||
first_token_ms: Option<u64>,
|
|
||||||
is_streaming: bool,
|
|
||||||
status_code: u16,
|
|
||||||
) {
|
|
||||||
use super::usage::logger::UsageLogger;
|
|
||||||
|
|
||||||
let logger = UsageLogger::new(&state.db);
|
|
||||||
|
|
||||||
// 获取 provider 的 cost_multiplier
|
|
||||||
let multiplier = match state.db.get_provider_by_id(provider_id, app_type) {
|
|
||||||
Ok(Some(p)) => {
|
|
||||||
if let Some(meta) = p.meta {
|
|
||||||
if let Some(cm) = meta.cost_multiplier {
|
|
||||||
Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
|
|
||||||
} else {
|
|
||||||
Decimal::from(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Decimal::from(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Decimal::from(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
let request_id = uuid::Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
if let Err(e) = logger.log_with_calculation(
|
|
||||||
request_id,
|
|
||||||
provider_id.to_string(),
|
|
||||||
app_type.to_string(),
|
|
||||||
model.to_string(),
|
|
||||||
usage,
|
|
||||||
multiplier,
|
|
||||||
latency_ms,
|
|
||||||
first_token_ms,
|
|
||||||
status_code,
|
|
||||||
None,
|
|
||||||
None, // provider_type
|
|
||||||
is_streaming,
|
|
||||||
) {
|
|
||||||
log::warn!("记录使用量失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 创建带日志记录的透传流
|
|
||||||
pub fn create_logged_passthrough_stream(
|
|
||||||
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
|
||||||
tag: &'static str,
|
|
||||||
usage_collector: Option<SseUsageCollector>,
|
|
||||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
|
||||||
async_stream::stream! {
|
|
||||||
let mut buffer = String::new();
|
|
||||||
let mut collector = usage_collector;
|
|
||||||
|
|
||||||
tokio::pin!(stream);
|
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
match chunk {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let text = String::from_utf8_lossy(&bytes);
|
|
||||||
buffer.push_str(&text);
|
|
||||||
|
|
||||||
// 尝试解析并记录完整的 SSE 事件
|
|
||||||
while let Some(pos) = buffer.find("\n\n") {
|
|
||||||
let event_text = buffer[..pos].to_string();
|
|
||||||
buffer = buffer[pos + 2..].to_string();
|
|
||||||
|
|
||||||
if !event_text.trim().is_empty() {
|
|
||||||
// 提取 data 部分并尝试解析为 JSON
|
|
||||||
for line in event_text.lines() {
|
|
||||||
if let Some(data) = line.strip_prefix("data: ") {
|
|
||||||
if data.trim() != "[DONE]" {
|
|
||||||
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
|
|
||||||
if let Some(c) = &collector {
|
|
||||||
c.push(json_value.clone()).await;
|
|
||||||
}
|
|
||||||
log::info!(
|
|
||||||
"[{}] <<< SSE 事件:\n{}",
|
|
||||||
tag,
|
|
||||||
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::info!("[{tag}] <<< SSE 数据: {data}");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::info!("[{tag}] <<< SSE: [DONE]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield Ok(bytes);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("[{tag}] 流错误: {e}");
|
|
||||||
yield Err(std::io::Error::other(e.to_string()));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[{}] ====== 流结束 ======", tag);
|
|
||||||
|
|
||||||
if let Some(c) = collector.take() {
|
|
||||||
c.finish().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! Provider路由器
|
||||||
|
//!
|
||||||
|
//! 负责选择合适的Provider进行请求转发
|
||||||
|
|
||||||
|
use super::ProxyError;
|
||||||
|
use crate::{app_config::AppType, database::Database, provider::Provider};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct ProviderRouter {
|
||||||
|
db: Arc<Database>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderRouter {
|
||||||
|
pub fn new(db: Arc<Database>) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择Provider(只使用标记为代理目标的 Provider)
|
||||||
|
pub async fn select_provider(
|
||||||
|
&self,
|
||||||
|
app_type: &AppType,
|
||||||
|
_failed_ids: &[String],
|
||||||
|
) -> Result<Provider, ProxyError> {
|
||||||
|
// 1. 获取 Proxy Target Provider ID
|
||||||
|
let proxy_target_id = self
|
||||||
|
.db
|
||||||
|
.get_proxy_target_provider(app_type.as_str())
|
||||||
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let target_id = proxy_target_id.ok_or_else(|| {
|
||||||
|
log::warn!("[{}] 未设置代理目标 Provider", app_type.as_str());
|
||||||
|
ProxyError::NoAvailableProvider
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 2. 获取所有 Provider
|
||||||
|
let providers = self
|
||||||
|
.db
|
||||||
|
.get_all_providers(app_type.as_str())
|
||||||
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
|
// 3. 找到目标 Provider
|
||||||
|
let target = providers.get(&target_id).ok_or_else(|| {
|
||||||
|
log::warn!(
|
||||||
|
"[{}] 代理目标 Provider 不存在: {}",
|
||||||
|
app_type.as_str(),
|
||||||
|
target_id
|
||||||
|
);
|
||||||
|
ProxyError::NoAvailableProvider
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[{}] 使用代理目标 Provider: {}",
|
||||||
|
app_type.as_str(),
|
||||||
|
target.name
|
||||||
|
);
|
||||||
|
Ok(target.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新Provider健康状态(保留接口但不影响选择)
|
||||||
|
pub async fn update_health(
|
||||||
|
&self,
|
||||||
|
_provider: &Provider,
|
||||||
|
_app_type: &AppType,
|
||||||
|
_success: bool,
|
||||||
|
_error_msg: Option<String>,
|
||||||
|
) {
|
||||||
|
// 不再记录健康状态
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 基于Axum的HTTP服务器,处理代理请求
|
//! 基于Axum的HTTP服务器,处理代理请求
|
||||||
|
|
||||||
use super::{
|
use super::{handlers, types::*, ProxyError};
|
||||||
failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
|
|
||||||
ProxyError,
|
|
||||||
};
|
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@@ -14,7 +11,6 @@ use axum::{
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{oneshot, RwLock};
|
use tokio::sync::{oneshot, RwLock};
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
/// 代理服务器状态(共享)
|
/// 代理服务器状态(共享)
|
||||||
@@ -24,14 +20,6 @@ pub struct ProxyState {
|
|||||||
pub config: Arc<RwLock<ProxyConfig>>,
|
pub config: Arc<RwLock<ProxyConfig>>,
|
||||||
pub status: Arc<RwLock<ProxyStatus>>,
|
pub status: Arc<RwLock<ProxyStatus>>,
|
||||||
pub start_time: Arc<RwLock<Option<std::time::Instant>>>,
|
pub start_time: Arc<RwLock<Option<std::time::Instant>>>,
|
||||||
/// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name))
|
|
||||||
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
|
||||||
/// 共享的 ProviderRouter(持有熔断器状态,跨请求保持)
|
|
||||||
pub provider_router: Arc<ProviderRouter>,
|
|
||||||
/// AppHandle,用于发射事件和更新托盘菜单
|
|
||||||
pub app_handle: Option<tauri::AppHandle>,
|
|
||||||
/// 故障转移切换管理器
|
|
||||||
pub failover_manager: Arc<FailoverSwitchManager>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 代理HTTP服务器
|
/// 代理HTTP服务器
|
||||||
@@ -39,37 +27,21 @@ pub struct ProxyServer {
|
|||||||
config: ProxyConfig,
|
config: ProxyConfig,
|
||||||
state: ProxyState,
|
state: ProxyState,
|
||||||
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
|
||||||
/// 服务器任务句柄,用于等待服务器实际关闭
|
|
||||||
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyServer {
|
impl ProxyServer {
|
||||||
pub fn new(
|
pub fn new(config: ProxyConfig, db: Arc<Database>) -> Self {
|
||||||
config: ProxyConfig,
|
|
||||||
db: Arc<Database>,
|
|
||||||
app_handle: Option<tauri::AppHandle>,
|
|
||||||
) -> Self {
|
|
||||||
// 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持)
|
|
||||||
let provider_router = Arc::new(ProviderRouter::new(db.clone()));
|
|
||||||
// 创建故障转移切换管理器
|
|
||||||
let failover_manager = Arc::new(FailoverSwitchManager::new(db.clone()));
|
|
||||||
|
|
||||||
let state = ProxyState {
|
let state = ProxyState {
|
||||||
db,
|
db,
|
||||||
config: Arc::new(RwLock::new(config.clone())),
|
config: Arc::new(RwLock::new(config.clone())),
|
||||||
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||||
start_time: Arc::new(RwLock::new(None)),
|
start_time: Arc::new(RwLock::new(None)),
|
||||||
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
|
||||||
provider_router,
|
|
||||||
app_handle,
|
|
||||||
failover_manager,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
state,
|
state,
|
||||||
shutdown_tx: Arc::new(RwLock::new(None)),
|
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||||
server_handle: Arc::new(RwLock::new(None)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +84,7 @@ impl ProxyServer {
|
|||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
let handle = tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(async {
|
.with_graceful_shutdown(async {
|
||||||
shutdown_rx.await.ok();
|
shutdown_rx.await.ok();
|
||||||
@@ -125,9 +97,6 @@ impl ProxyServer {
|
|||||||
*state.start_time.write().await = None;
|
*state.start_time.write().await = None;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 保存服务器任务句柄
|
|
||||||
*self.server_handle.write().await = Some(handle);
|
|
||||||
|
|
||||||
Ok(ProxyServerInfo {
|
Ok(ProxyServerInfo {
|
||||||
address: self.config.listen_address.clone(),
|
address: self.config.listen_address.clone(),
|
||||||
port: self.config.listen_port,
|
port: self.config.listen_port,
|
||||||
@@ -136,23 +105,12 @@ impl ProxyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<(), ProxyError> {
|
pub async fn stop(&self) -> Result<(), ProxyError> {
|
||||||
// 1. 发送关闭信号
|
|
||||||
if let Some(tx) = self.shutdown_tx.write().await.take() {
|
if let Some(tx) = self.shutdown_tx.write().await.take() {
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
return Err(ProxyError::NotRunning);
|
Err(ProxyError::NotRunning)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 等待服务器任务结束(带 5 秒超时保护)
|
|
||||||
if let Some(handle) = self.server_handle.write().await.take() {
|
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
|
|
||||||
Ok(Ok(())) => log::info!("代理服务器已完全停止"),
|
|
||||||
Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"),
|
|
||||||
Err(_) => log::warn!("代理服务器停止超时(5秒),强制继续"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_status(&self) -> ProxyStatus {
|
pub async fn get_status(&self) -> ProxyStatus {
|
||||||
@@ -163,16 +121,17 @@ impl ProxyServer {
|
|||||||
status.uptime_seconds = start.elapsed().as_secs();
|
status.uptime_seconds = start.elapsed().as_secs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider
|
// 获取所有活跃的代理目标
|
||||||
let current_providers = self.state.current_providers.read().await;
|
if let Ok(targets) = self.state.db.get_all_proxy_targets() {
|
||||||
status.active_targets = current_providers
|
status.active_targets = targets
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|(app_type, (provider_id, provider_name))| ActiveTarget {
|
.map(|(app_type, name, id)| ActiveTarget {
|
||||||
app_type: app_type.clone(),
|
app_type,
|
||||||
provider_id: provider_id.clone(),
|
provider_name: name,
|
||||||
provider_name: provider_name.clone(),
|
provider_id: id,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
status
|
status
|
||||||
}
|
}
|
||||||
@@ -187,24 +146,17 @@ impl ProxyServer {
|
|||||||
// 健康检查
|
// 健康检查
|
||||||
.route("/health", get(handlers::health_check))
|
.route("/health", get(handlers::health_check))
|
||||||
.route("/status", get(handlers::get_status))
|
.route("/status", get(handlers::get_status))
|
||||||
// Claude API (支持带前缀和不带前缀两种格式)
|
// Claude API
|
||||||
.route("/v1/messages", post(handlers::handle_messages))
|
.route("/v1/messages", post(handlers::handle_messages))
|
||||||
.route("/claude/v1/messages", post(handlers::handle_messages))
|
// OpenAI Chat Completions API (Codex CLI)
|
||||||
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
|
|
||||||
.route(
|
.route(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
post(handlers::handle_chat_completions),
|
post(handlers::handle_chat_completions),
|
||||||
)
|
)
|
||||||
.route(
|
// OpenAI Responses API (Codex CLI)
|
||||||
"/codex/v1/chat/completions",
|
|
||||||
post(handlers::handle_chat_completions),
|
|
||||||
)
|
|
||||||
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
|
|
||||||
.route("/v1/responses", post(handlers::handle_responses))
|
.route("/v1/responses", post(handlers::handle_responses))
|
||||||
.route("/codex/v1/responses", post(handlers::handle_responses))
|
// Gemini API
|
||||||
// Gemini API (支持带前缀和不带前缀)
|
|
||||||
.route("/v1beta/*path", post(handlers::handle_gemini))
|
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(self.state.clone())
|
.with_state(self.state.clone())
|
||||||
}
|
}
|
||||||
@@ -213,22 +165,4 @@ impl ProxyServer {
|
|||||||
pub async fn apply_runtime_config(&self, config: &ProxyConfig) {
|
pub async fn apply_runtime_config(&self, config: &ProxyConfig) {
|
||||||
*self.state.config.write().await = config.clone();
|
*self.state.config.write().await = config.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 热更新熔断器配置
|
|
||||||
///
|
|
||||||
/// 将新配置应用到所有已创建的熔断器实例
|
|
||||||
pub async fn update_circuit_breaker_configs(
|
|
||||||
&self,
|
|
||||||
config: super::circuit_breaker::CircuitBreakerConfig,
|
|
||||||
) {
|
|
||||||
self.state.provider_router.update_all_configs(config).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置指定 Provider 的熔断器
|
|
||||||
pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) {
|
|
||||||
self.state
|
|
||||||
.provider_router
|
|
||||||
.reset_provider_breaker(provider_id, app_type)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ pub struct ProxyConfig {
|
|||||||
pub request_timeout: u64,
|
pub request_timeout: u64,
|
||||||
/// 是否启用日志
|
/// 是否启用日志
|
||||||
pub enable_logging: bool,
|
pub enable_logging: bool,
|
||||||
/// 是否正在接管 Live 配置
|
|
||||||
#[serde(default)]
|
|
||||||
pub live_takeover_active: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProxyConfig {
|
impl Default for ProxyConfig {
|
||||||
@@ -29,7 +26,6 @@ impl Default for ProxyConfig {
|
|||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
request_timeout: 300,
|
request_timeout: 300,
|
||||||
enable_logging: true,
|
enable_logging: true,
|
||||||
live_takeover_active: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,13 +104,16 @@ pub struct ProviderHealth {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live 配置备份记录
|
/// 使用统计记录
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LiveBackup {
|
pub struct ProxyUsageRecord {
|
||||||
/// 应用类型 (claude/codex/gemini)
|
pub provider_id: String,
|
||||||
pub app_type: String,
|
pub app_type: String,
|
||||||
/// 原始配置 JSON
|
pub endpoint: String,
|
||||||
pub original_config: String,
|
pub request_tokens: Option<i32>,
|
||||||
/// 备份时间
|
pub response_tokens: Option<i32>,
|
||||||
pub backed_up_at: String,
|
pub status_code: u16,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub timestamp: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,8 +107,6 @@ impl<'a> UsageLogger<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 记录失败的请求
|
/// 记录失败的请求
|
||||||
///
|
|
||||||
/// 用于记录无法从上游获取 usage 信息的失败请求
|
|
||||||
#[allow(dead_code, clippy::too_many_arguments)]
|
#[allow(dead_code, clippy::too_many_arguments)]
|
||||||
pub fn log_error(
|
pub fn log_error(
|
||||||
&self,
|
&self,
|
||||||
@@ -140,43 +138,6 @@ impl<'a> UsageLogger<'a> {
|
|||||||
self.log_request(&log)
|
self.log_request(&log)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录失败的请求(带更多上下文信息)
|
|
||||||
///
|
|
||||||
/// 相比 log_error,这个方法接受更多参数以提供完整的请求上下文
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn log_error_with_context(
|
|
||||||
&self,
|
|
||||||
request_id: String,
|
|
||||||
provider_id: String,
|
|
||||||
app_type: String,
|
|
||||||
model: String,
|
|
||||||
status_code: u16,
|
|
||||||
error_message: String,
|
|
||||||
latency_ms: u64,
|
|
||||||
is_streaming: bool,
|
|
||||||
session_id: Option<String>,
|
|
||||||
provider_type: Option<String>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let log = RequestLog {
|
|
||||||
request_id,
|
|
||||||
provider_id,
|
|
||||||
app_type,
|
|
||||||
model,
|
|
||||||
usage: TokenUsage::default(),
|
|
||||||
cost: None,
|
|
||||||
latency_ms,
|
|
||||||
first_token_ms: None,
|
|
||||||
status_code,
|
|
||||||
error_message: Some(error_message),
|
|
||||||
session_id,
|
|
||||||
provider_type,
|
|
||||||
is_streaming,
|
|
||||||
cost_multiplier: "1.0".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.log_request(&log)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取模型定价
|
/// 获取模型定价
|
||||||
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
|
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
|
||||||
let conn = crate::database::lock_conn!(self.db.conn);
|
let conn = crate::database::lock_conn!(self.db.conn);
|
||||||
|
|||||||
@@ -2,16 +2,18 @@ pub mod config;
|
|||||||
pub mod env_checker;
|
pub mod env_checker;
|
||||||
pub mod env_manager;
|
pub mod env_manager;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod model_test;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
pub mod stream_check;
|
|
||||||
pub mod usage_stats;
|
pub mod usage_stats;
|
||||||
|
|
||||||
pub use config::ConfigService;
|
pub use config::ConfigService;
|
||||||
pub use mcp::McpService;
|
pub use mcp::McpService;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use model_test::{ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService};
|
||||||
pub use prompt::PromptService;
|
pub use prompt::PromptService;
|
||||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||||
pub use proxy::ProxyService;
|
pub use proxy::ProxyService;
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
//! 模型测试服务
|
||||||
|
//!
|
||||||
|
//! 提供独立的模型可用性测试功能,复用现有 Provider 适配器逻辑,
|
||||||
|
//! 但不影响正常代理数据流程。测试结果记录到独立的日志表。
|
||||||
|
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use crate::proxy::providers::{get_adapter, AuthInfo, ProviderAdapter};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// 模型测试配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelTestConfig {
|
||||||
|
/// 默认测试模型(Claude)
|
||||||
|
pub claude_model: String,
|
||||||
|
/// 默认测试模型(Codex/OpenAI)
|
||||||
|
pub codex_model: String,
|
||||||
|
/// 默认测试模型(Gemini)
|
||||||
|
pub gemini_model: String,
|
||||||
|
/// 测试提示词
|
||||||
|
pub test_prompt: String,
|
||||||
|
/// 超时时间(秒)
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelTestConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||||
|
codex_model: "gpt-5.1-low".to_string(),
|
||||||
|
gemini_model: "gemini-3-pro-low".to_string(),
|
||||||
|
test_prompt: "ping".to_string(),
|
||||||
|
timeout_secs: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型测试结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelTestResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub response_time_ms: Option<u64>,
|
||||||
|
pub http_status: Option<u16>,
|
||||||
|
pub model_used: String,
|
||||||
|
pub tested_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型测试日志记录
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ModelTestLog {
|
||||||
|
pub id: i64,
|
||||||
|
pub provider_id: String,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub app_type: String,
|
||||||
|
pub model: String,
|
||||||
|
pub prompt: String,
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub response_time_ms: Option<i64>,
|
||||||
|
pub http_status: Option<i64>,
|
||||||
|
pub tested_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型测试服务
|
||||||
|
pub struct ModelTestService;
|
||||||
|
|
||||||
|
impl ModelTestService {
|
||||||
|
/// 测试单个供应商的模型可用性
|
||||||
|
pub async fn test_provider(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &Provider,
|
||||||
|
config: &ModelTestConfig,
|
||||||
|
) -> Result<ModelTestResult, AppError> {
|
||||||
|
let start = Instant::now();
|
||||||
|
let adapter = get_adapter(app_type);
|
||||||
|
|
||||||
|
// 构建 HTTP 客户端(独立于代理服务)
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(config.timeout_secs))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))?;
|
||||||
|
|
||||||
|
// 根据 AppType 选择测试模型
|
||||||
|
let model = match app_type {
|
||||||
|
AppType::Claude => &config.claude_model,
|
||||||
|
AppType::Codex => &config.codex_model,
|
||||||
|
AppType::Gemini => &config.gemini_model,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = match app_type {
|
||||||
|
AppType::Claude => {
|
||||||
|
Self::test_claude(
|
||||||
|
&client,
|
||||||
|
provider,
|
||||||
|
adapter.as_ref(),
|
||||||
|
model,
|
||||||
|
&config.test_prompt,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
AppType::Codex => {
|
||||||
|
Self::test_codex(
|
||||||
|
&client,
|
||||||
|
provider,
|
||||||
|
adapter.as_ref(),
|
||||||
|
model,
|
||||||
|
&config.test_prompt,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
AppType::Gemini => {
|
||||||
|
Self::test_gemini(
|
||||||
|
&client,
|
||||||
|
provider,
|
||||||
|
adapter.as_ref(),
|
||||||
|
model,
|
||||||
|
&config.test_prompt,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response_time = start.elapsed().as_millis() as u64;
|
||||||
|
let tested_at = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((status, msg)) => Ok(ModelTestResult {
|
||||||
|
success: true,
|
||||||
|
message: msg,
|
||||||
|
response_time_ms: Some(response_time),
|
||||||
|
http_status: Some(status),
|
||||||
|
model_used: model.clone(),
|
||||||
|
tested_at,
|
||||||
|
}),
|
||||||
|
Err(e) => Ok(ModelTestResult {
|
||||||
|
success: false,
|
||||||
|
message: e.to_string(),
|
||||||
|
response_time_ms: Some(response_time),
|
||||||
|
http_status: None,
|
||||||
|
model_used: model.clone(),
|
||||||
|
tested_at,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 Claude (Anthropic Messages API)
|
||||||
|
async fn test_claude(
|
||||||
|
client: &Client,
|
||||||
|
provider: &Provider,
|
||||||
|
adapter: &dyn ProviderAdapter,
|
||||||
|
model: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<(u16, String), AppError> {
|
||||||
|
let base_url = adapter
|
||||||
|
.extract_base_url(provider)
|
||||||
|
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||||
|
|
||||||
|
let auth = adapter
|
||||||
|
.extract_auth(provider)
|
||||||
|
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||||
|
|
||||||
|
// 智能拼接 URL,避免重复 /v1
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let url = if base.ends_with("/v1") {
|
||||||
|
format!("{base}/messages")
|
||||||
|
} else {
|
||||||
|
format!("{base}/v1/messages")
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": 1,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut request = client.post(&url).json(&body);
|
||||||
|
request = Self::add_claude_auth(request, &auth);
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
AppError::Message("请求超时".to_string())
|
||||||
|
} else if e.is_connect() {
|
||||||
|
AppError::Message(format!("连接失败: {e}"))
|
||||||
|
} else {
|
||||||
|
AppError::Message(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
// 先获取文本,再尝试解析 JSON(兼容流式响应)
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
|
||||||
|
// 尝试解析 JSON
|
||||||
|
if let Ok(data) = serde_json::from_str::<Value>(&text) {
|
||||||
|
if data.get("type").is_some()
|
||||||
|
|| data.get("content").is_some()
|
||||||
|
|| data.get("id").is_some()
|
||||||
|
{
|
||||||
|
return Ok((status, "模型测试成功".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 即使无法解析 JSON,只要状态码是 200 就认为成功
|
||||||
|
Ok((status, "模型测试成功".to_string()))
|
||||||
|
} else {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 Codex (OpenAI Chat Completions API)
|
||||||
|
async fn test_codex(
|
||||||
|
client: &Client,
|
||||||
|
provider: &Provider,
|
||||||
|
adapter: &dyn ProviderAdapter,
|
||||||
|
model: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<(u16, String), AppError> {
|
||||||
|
let base_url = adapter
|
||||||
|
.extract_base_url(provider)
|
||||||
|
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||||
|
|
||||||
|
let auth = adapter
|
||||||
|
.extract_auth(provider)
|
||||||
|
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||||
|
|
||||||
|
// 智能拼接 URL,避免重复 /v1
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let url = if base.ends_with("/v1") {
|
||||||
|
format!("{base}/chat/completions")
|
||||||
|
} else {
|
||||||
|
format!("{base}/v1/chat/completions")
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}],
|
||||||
|
"max_tokens": 1,
|
||||||
|
"stream": false
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body);
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
AppError::Message("请求超时".to_string())
|
||||||
|
} else if e.is_connect() {
|
||||||
|
AppError::Message(format!("连接失败: {e}"))
|
||||||
|
} else {
|
||||||
|
AppError::Message(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
// 先获取文本,再尝试解析 JSON
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if let Ok(data) = serde_json::from_str::<Value>(&text) {
|
||||||
|
if data.get("choices").is_some() || data.get("id").is_some() {
|
||||||
|
return Ok((status, "模型测试成功".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 即使无法解析 JSON,只要状态码是 200 就认为成功
|
||||||
|
Ok((status, "模型测试成功".to_string()))
|
||||||
|
} else {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 Gemini (Google Generative AI API)
|
||||||
|
async fn test_gemini(
|
||||||
|
client: &Client,
|
||||||
|
provider: &Provider,
|
||||||
|
adapter: &dyn ProviderAdapter,
|
||||||
|
model: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<(u16, String), AppError> {
|
||||||
|
let base_url = adapter
|
||||||
|
.extract_base_url(provider)
|
||||||
|
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
||||||
|
|
||||||
|
let auth = adapter
|
||||||
|
.extract_auth(provider)
|
||||||
|
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1beta/models/{}:generateContent?key={}",
|
||||||
|
base_url.trim_end_matches('/'),
|
||||||
|
model,
|
||||||
|
auth.api_key
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"contents": [{
|
||||||
|
"parts": [{
|
||||||
|
"text": prompt
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"generationConfig": {
|
||||||
|
"maxOutputTokens": 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&body);
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
if e.is_timeout() {
|
||||||
|
AppError::Message("请求超时".to_string())
|
||||||
|
} else if e.is_connect() {
|
||||||
|
AppError::Message(format!("连接失败: {e}"))
|
||||||
|
} else {
|
||||||
|
AppError::Message(e.to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
let data: Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Message(format!("解析响应失败: {e}")))?;
|
||||||
|
|
||||||
|
if data.get("candidates").is_some() {
|
||||||
|
Ok((status, "模型测试成功".to_string()))
|
||||||
|
} else {
|
||||||
|
Err(AppError::Message("响应格式异常".to_string()))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加 Claude 认证头
|
||||||
|
fn add_claude_auth(
|
||||||
|
request: reqwest::RequestBuilder,
|
||||||
|
auth: &AuthInfo,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
request
|
||||||
|
.header("x-api-key", &auth.api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 数据库操作 =====
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// 保存模型测试日志
|
||||||
|
pub fn save_model_test_log(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
provider_name: &str,
|
||||||
|
app_type: &str,
|
||||||
|
model: &str,
|
||||||
|
prompt: &str,
|
||||||
|
result: &ModelTestResult,
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO model_test_logs
|
||||||
|
(provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
rusqlite::params![
|
||||||
|
provider_id,
|
||||||
|
provider_name,
|
||||||
|
app_type,
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
result.success,
|
||||||
|
result.message,
|
||||||
|
result.response_time_ms.map(|t| t as i64),
|
||||||
|
result.http_status.map(|s| s as i64),
|
||||||
|
result.tested_at,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型测试日志
|
||||||
|
pub fn get_model_test_logs(
|
||||||
|
&self,
|
||||||
|
app_type: Option<&str>,
|
||||||
|
provider_id: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<ModelTestLog>, AppError> {
|
||||||
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
|
||||||
|
|
||||||
|
let mut sql = String::from(
|
||||||
|
"SELECT id, provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at
|
||||||
|
FROM model_test_logs WHERE 1=1"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(at) = app_type {
|
||||||
|
sql.push_str(" AND app_type = ?");
|
||||||
|
params.push(Box::new(at.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pid) = provider_id {
|
||||||
|
sql.push_str(" AND provider_id = ?");
|
||||||
|
params.push(Box::new(pid.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.push_str(" ORDER BY tested_at DESC LIMIT ?");
|
||||||
|
params.push(Box::new(limit as i64));
|
||||||
|
|
||||||
|
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&sql)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let logs = stmt
|
||||||
|
.query_map(params_refs.as_slice(), |row| {
|
||||||
|
Ok(ModelTestLog {
|
||||||
|
id: row.get(0)?,
|
||||||
|
provider_id: row.get(1)?,
|
||||||
|
provider_name: row.get(2)?,
|
||||||
|
app_type: row.get(3)?,
|
||||||
|
model: row.get(4)?,
|
||||||
|
prompt: row.get(5)?,
|
||||||
|
success: row.get(6)?,
|
||||||
|
message: row.get(7)?,
|
||||||
|
response_time_ms: row.get(8)?,
|
||||||
|
http_status: row.get(9)?,
|
||||||
|
tested_at: row.get(10)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取模型测试配置
|
||||||
|
pub fn get_model_test_config(&self) -> Result<ModelTestConfig, AppError> {
|
||||||
|
match self.get_setting("model_test_config")? {
|
||||||
|
Some(json) => serde_json::from_str(&json)
|
||||||
|
.map_err(|e| AppError::Message(format!("解析模型测试配置失败: {e}"))),
|
||||||
|
None => Ok(ModelTestConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存模型测试配置
|
||||||
|
pub fn save_model_test_config(&self, config: &ModelTestConfig) -> Result<(), AppError> {
|
||||||
|
let json = serde_json::to_string(config)
|
||||||
|
.map_err(|e| AppError::Message(format!("序列化模型测试配置失败: {e}")))?;
|
||||||
|
self.set_setting("model_test_config", &json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理旧的测试日志(保留最近 N 条)
|
||||||
|
pub fn cleanup_model_test_logs(&self, keep_count: u32) -> Result<u64, AppError> {
|
||||||
|
let conn = self
|
||||||
|
.conn
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
|
||||||
|
|
||||||
|
let deleted = conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM model_test_logs WHERE id NOT IN (
|
||||||
|
SELECT id FROM model_test_logs ORDER BY tested_at DESC LIMIT ?
|
||||||
|
)",
|
||||||
|
rusqlite::params![keep_count as i64],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(deleted as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,81 +173,14 @@ impl ProviderService {
|
|||||||
///
|
///
|
||||||
/// Switch flow:
|
/// Switch flow:
|
||||||
/// 1. Validate target provider exists
|
/// 1. Validate target provider exists
|
||||||
/// 2. Check if proxy takeover mode is active AND proxy server is running
|
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
|
||||||
/// 3. If takeover mode active: hot-switch proxy target only (no Live config write)
|
/// 3. Update local settings current_provider_xxx (device-level)
|
||||||
/// 4. If normal mode:
|
/// 4. Update database is_current (as default for new devices)
|
||||||
/// a. **Backfill mechanism**: Backfill current live config to current provider
|
/// 5. Write target provider config to live files
|
||||||
/// b. Update local settings current_provider_xxx (device-level)
|
/// 6. Sync MCP configuration
|
||||||
/// c. Update database is_current (as default for new devices)
|
|
||||||
/// d. Write target provider config to live files
|
|
||||||
/// e. Sync MCP configuration
|
|
||||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
// Check if provider exists
|
// Check if provider exists
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
let _provider = providers
|
|
||||||
.get(id)
|
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
|
||||||
|
|
||||||
// Check if proxy takeover mode is active AND proxy server is actually running
|
|
||||||
// Both conditions must be true to use hot-switch mode
|
|
||||||
// Use blocking wait since this is a sync function
|
|
||||||
let is_takeover_flag =
|
|
||||||
futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false);
|
|
||||||
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
|
|
||||||
|
|
||||||
// Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running
|
|
||||||
let should_hot_switch = is_takeover_flag && is_proxy_running;
|
|
||||||
|
|
||||||
if should_hot_switch {
|
|
||||||
// Proxy takeover mode: hot-switch only, don't write Live config
|
|
||||||
log::info!(
|
|
||||||
"代理接管模式:热切换 {} 的目标供应商为 {}",
|
|
||||||
app_type.as_str(),
|
|
||||||
id
|
|
||||||
);
|
|
||||||
|
|
||||||
// 获取新供应商的完整配置(用于更新备份)
|
|
||||||
let provider = providers
|
|
||||||
.get(id)
|
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
|
||||||
|
|
||||||
// Update database is_current
|
|
||||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
|
||||||
|
|
||||||
// Update local settings for consistency
|
|
||||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
|
||||||
|
|
||||||
// 更新 Live 备份(确保代理关闭时恢复正确的供应商配置)
|
|
||||||
futures::executor::block_on(
|
|
||||||
state
|
|
||||||
.proxy_service
|
|
||||||
.update_live_backup_from_provider(app_type.as_str(), provider),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
|
||||||
|
|
||||||
// Note: No Live config write, no MCP sync
|
|
||||||
// The proxy server will route requests to the new provider via is_current
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal mode: full switch with Live config write
|
|
||||||
// Also clear stale takeover flag if proxy is not running but flag was set
|
|
||||||
if is_takeover_flag && !is_proxy_running {
|
|
||||||
log::warn!("检测到代理接管标志残留(代理已停止),清除标志并执行正常切换");
|
|
||||||
// Clear stale takeover flag
|
|
||||||
let _ = futures::executor::block_on(state.db.set_live_takeover_active(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::switch_normal(state, app_type, id, &providers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normal switch flow (non-proxy mode)
|
|
||||||
fn switch_normal(
|
|
||||||
state: &AppState,
|
|
||||||
app_type: AppType,
|
|
||||||
id: &str,
|
|
||||||
providers: &indexmap::IndexMap<String, Provider>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let provider = providers
|
let provider = providers
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||||
@@ -284,6 +217,18 @@ impl ProviderService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set proxy target provider
|
||||||
|
pub fn set_proxy_target(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||||
|
// Check if provider exists
|
||||||
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
if !providers.contains_key(id) {
|
||||||
|
return Err(AppError::Message(format!("供应商 {id} 不存在")));
|
||||||
|
}
|
||||||
|
|
||||||
|
state.db.set_proxy_target_provider(app_type.as_str(), id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync current provider to live configuration (re-export)
|
/// Sync current provider to live configuration (re-export)
|
||||||
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
||||||
sync_current_to_live(state)
|
sync_current_to_live(state)
|
||||||
|
|||||||
@@ -79,34 +79,6 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract API key from provider configuration
|
|
||||||
fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
|
||||||
if let Some(env) = provider.settings_config.get("env") {
|
|
||||||
// Try multiple possible API key fields
|
|
||||||
env.get("ANTHROPIC_AUTH_TOKEN")
|
|
||||||
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
|
||||||
.or_else(|| env.get("OPENROUTER_API_KEY"))
|
|
||||||
.or_else(|| env.get("GOOGLE_API_KEY"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract base URL from provider configuration
|
|
||||||
fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
|
||||||
if let Some(env) = provider.settings_config.get("env") {
|
|
||||||
// Try multiple possible base URL fields
|
|
||||||
env.get("ANTHROPIC_BASE_URL")
|
|
||||||
.or_else(|| env.get("GOOGLE_GEMINI_BASE_URL"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.trim_end_matches('/').to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query provider usage (using saved script configuration)
|
/// Query provider usage (using saved script configuration)
|
||||||
pub async fn query_usage(
|
pub async fn query_usage(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -142,26 +114,12 @@ pub async fn query_usage(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get credentials: prioritize UsageScript values, fallback to provider config
|
// Get credentials directly from UsageScript, no longer extract from provider config
|
||||||
let api_key = usage_script
|
|
||||||
.api_key
|
|
||||||
.clone()
|
|
||||||
.filter(|k| !k.is_empty())
|
|
||||||
.or_else(|| extract_api_key_from_provider(provider))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let base_url = usage_script
|
|
||||||
.base_url
|
|
||||||
.clone()
|
|
||||||
.filter(|u| !u.is_empty())
|
|
||||||
.or_else(|| extract_base_url_from_provider(provider))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
(
|
(
|
||||||
usage_script.code.clone(),
|
usage_script.code.clone(),
|
||||||
usage_script.timeout.unwrap_or(10),
|
usage_script.timeout.unwrap_or(10),
|
||||||
api_key,
|
usage_script.api_key.clone().unwrap_or_default(),
|
||||||
base_url,
|
usage_script.base_url.clone().unwrap_or_default(),
|
||||||
usage_script.access_token.clone(),
|
usage_script.access_token.clone(),
|
||||||
usage_script.user_id.clone(),
|
usage_script.user_id.clone(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,14 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! 提供代理服务器的启动、停止和配置管理
|
//! 提供代理服务器的启动、停止和配置管理
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
|
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
@@ -17,8 +12,6 @@ use tokio::sync::RwLock;
|
|||||||
pub struct ProxyService {
|
pub struct ProxyService {
|
||||||
db: Arc<Database>,
|
db: Arc<Database>,
|
||||||
server: Arc<RwLock<Option<ProxyServer>>>,
|
server: Arc<RwLock<Option<ProxyServer>>>,
|
||||||
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
|
|
||||||
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyService {
|
impl ProxyService {
|
||||||
@@ -26,17 +19,9 @@ impl ProxyService {
|
|||||||
Self {
|
Self {
|
||||||
db,
|
db,
|
||||||
server: Arc::new(RwLock::new(None)),
|
server: Arc::new(RwLock::new(None)),
|
||||||
app_handle: Arc::new(RwLock::new(None)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 AppHandle(在应用初始化时调用)
|
|
||||||
pub fn set_app_handle(&self, handle: tauri::AppHandle) {
|
|
||||||
futures::executor::block_on(async {
|
|
||||||
*self.app_handle.write().await = Some(handle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 启动代理服务器
|
/// 启动代理服务器
|
||||||
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
|
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
|
||||||
// 1. 获取配置
|
// 1. 获取配置
|
||||||
@@ -55,8 +40,7 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 创建并启动服务器
|
// 4. 创建并启动服务器
|
||||||
let app_handle = self.app_handle.read().await.clone();
|
let server = ProxyServer::new(config.clone(), self.db.clone());
|
||||||
let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);
|
|
||||||
let info = server
|
let info = server
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
@@ -75,165 +59,6 @@ impl ProxyService {
|
|||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动代理服务器(带 Live 配置接管)
|
|
||||||
pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {
|
|
||||||
// 1. 备份各应用的 Live 配置
|
|
||||||
self.backup_live_configs().await?;
|
|
||||||
|
|
||||||
// 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)
|
|
||||||
self.sync_live_to_providers().await?;
|
|
||||||
|
|
||||||
// 3. 接管各应用的 Live 配置(写入代理地址,清空 Token)
|
|
||||||
self.takeover_live_configs().await?;
|
|
||||||
|
|
||||||
// 4. 设置接管状态
|
|
||||||
self.db
|
|
||||||
.set_live_takeover_active(true)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("设置接管状态失败: {e}"))?;
|
|
||||||
|
|
||||||
// 5. 启动代理服务器
|
|
||||||
match self.start().await {
|
|
||||||
Ok(info) => Ok(info),
|
|
||||||
Err(e) => {
|
|
||||||
// 启动失败,恢复原始配置
|
|
||||||
log::error!("代理启动失败,尝试恢复原始配置: {e}");
|
|
||||||
let _ = self.restore_live_configs().await;
|
|
||||||
let _ = self.db.set_live_takeover_active(false).await;
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同步 Live 配置中的 Token 到数据库
|
|
||||||
///
|
|
||||||
/// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。
|
|
||||||
/// 这样代理才能从数据库读取到正确的认证信息。
|
|
||||||
async fn sync_live_to_providers(&self) -> Result<(), String> {
|
|
||||||
// Claude: 同步 ANTHROPIC_AUTH_TOKEN
|
|
||||||
if let Ok(live_config) = self.read_claude_live() {
|
|
||||||
if let Some(provider_id) = self.db.get_current_provider("claude").ok().flatten() {
|
|
||||||
if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "claude") {
|
|
||||||
// 从 live 配置提取 token
|
|
||||||
if let Some(env) = live_config.get("env") {
|
|
||||||
if let Some(token) =
|
|
||||||
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
if !token.is_empty() {
|
|
||||||
// 更新 provider 的 settings_config
|
|
||||||
if let Some(env_obj) = provider
|
|
||||||
.settings_config
|
|
||||||
.get_mut("env")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
env_obj
|
|
||||||
.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(token));
|
|
||||||
} else {
|
|
||||||
provider.settings_config["env"] = json!({
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": token
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 保存到数据库
|
|
||||||
if let Err(e) = self.db.update_provider_settings_config(
|
|
||||||
"claude",
|
|
||||||
&provider_id,
|
|
||||||
&provider.settings_config,
|
|
||||||
) {
|
|
||||||
log::warn!("同步 Claude Token 到数据库失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"已同步 Claude Token 到数据库 (provider: {provider_id})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex: 同步 OPENAI_API_KEY
|
|
||||||
if let Ok(live_config) = self.read_codex_live() {
|
|
||||||
if let Some(provider_id) = self.db.get_current_provider("codex").ok().flatten() {
|
|
||||||
if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "codex") {
|
|
||||||
// 从 live 配置提取 token
|
|
||||||
if let Some(auth) = live_config.get("auth") {
|
|
||||||
if let Some(token) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
|
|
||||||
if !token.is_empty() {
|
|
||||||
// 更新 provider 的 settings_config
|
|
||||||
if let Some(auth_obj) = provider
|
|
||||||
.settings_config
|
|
||||||
.get_mut("auth")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
auth_obj.insert("OPENAI_API_KEY".to_string(), json!(token));
|
|
||||||
} else {
|
|
||||||
provider.settings_config["auth"] = json!({
|
|
||||||
"OPENAI_API_KEY": token
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 保存到数据库
|
|
||||||
if let Err(e) = self.db.update_provider_settings_config(
|
|
||||||
"codex",
|
|
||||||
&provider_id,
|
|
||||||
&provider.settings_config,
|
|
||||||
) {
|
|
||||||
log::warn!("同步 Codex Token 到数据库失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"已同步 Codex Token 到数据库 (provider: {provider_id})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini: 同步 GEMINI_API_KEY
|
|
||||||
if let Ok(live_config) = self.read_gemini_live() {
|
|
||||||
if let Some(provider_id) = self.db.get_current_provider("gemini").ok().flatten() {
|
|
||||||
if let Ok(Some(mut provider)) = self.db.get_provider_by_id(&provider_id, "gemini") {
|
|
||||||
// 从 live 配置提取 token
|
|
||||||
if let Some(env) = live_config.get("env") {
|
|
||||||
if let Some(token) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
|
||||||
if !token.is_empty() {
|
|
||||||
// 更新 provider 的 settings_config
|
|
||||||
if let Some(env_obj) = provider
|
|
||||||
.settings_config
|
|
||||||
.get_mut("env")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
env_obj.insert("GEMINI_API_KEY".to_string(), json!(token));
|
|
||||||
} else {
|
|
||||||
provider.settings_config["env"] = json!({
|
|
||||||
"GEMINI_API_KEY": token
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 保存到数据库
|
|
||||||
if let Err(e) = self.db.update_provider_settings_config(
|
|
||||||
"gemini",
|
|
||||||
&provider_id,
|
|
||||||
&provider.settings_config,
|
|
||||||
) {
|
|
||||||
log::warn!("同步 Gemini Token 到数据库失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"已同步 Gemini Token 到数据库 (provider: {provider_id})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Live 配置 Token 同步完成");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 停止代理服务器
|
/// 停止代理服务器
|
||||||
pub async fn stop(&self) -> Result<(), String> {
|
pub async fn stop(&self) -> Result<(), String> {
|
||||||
if let Some(server) = self.server.write().await.take() {
|
if let Some(server) = self.server.write().await.take() {
|
||||||
@@ -241,13 +66,6 @@ impl ProxyService {
|
|||||||
.stop()
|
.stop()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
|
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
|
||||||
|
|
||||||
// 将 enabled 设为 false,避免下次启动时自动开启
|
|
||||||
if let Ok(mut config) = self.db.get_proxy_config().await {
|
|
||||||
config.enabled = false;
|
|
||||||
let _ = self.db.update_proxy_config(config).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("代理服务器已停止");
|
log::info!("代理服务器已停止");
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -255,357 +73,6 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止代理服务器(恢复 Live 配置)
|
|
||||||
pub async fn stop_with_restore(&self) -> Result<(), String> {
|
|
||||||
// 1. 停止代理服务器
|
|
||||||
self.stop().await?;
|
|
||||||
|
|
||||||
// 2. 恢复原始 Live 配置
|
|
||||||
self.restore_live_configs().await?;
|
|
||||||
|
|
||||||
// 3. 清除接管状态
|
|
||||||
self.db
|
|
||||||
.set_live_takeover_active(false)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("清除接管状态失败: {e}"))?;
|
|
||||||
|
|
||||||
// 4. 删除备份
|
|
||||||
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 配置
|
|
||||||
async fn backup_live_configs(&self) -> Result<(), String> {
|
|
||||||
// Claude
|
|
||||||
if let Ok(config) = self.read_claude_live() {
|
|
||||||
let json_str = serde_json::to_string(&config)
|
|
||||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?;
|
|
||||||
self.db
|
|
||||||
.save_live_backup("claude", &json_str)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("备份 Claude 配置失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex
|
|
||||||
if let Ok(config) = self.read_codex_live() {
|
|
||||||
let json_str = serde_json::to_string(&config)
|
|
||||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
|
|
||||||
self.db
|
|
||||||
.save_live_backup("codex", &json_str)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("备份 Codex 配置失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini
|
|
||||||
if let Ok(config) = self.read_gemini_live() {
|
|
||||||
let json_str = serde_json::to_string(&config)
|
|
||||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?;
|
|
||||||
self.db
|
|
||||||
.save_live_backup("gemini", &json_str)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("备份 Gemini 配置失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("已备份所有应用的 Live 配置");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 接管各应用的 Live 配置(写入代理地址)
|
|
||||||
///
|
|
||||||
/// 代理服务器的路由已经根据 API 端点自动区分应用类型:
|
|
||||||
/// - `/v1/messages` → Claude
|
|
||||||
/// - `/v1/chat/completions`, `/v1/responses` → Codex
|
|
||||||
/// - `/v1beta/*` → Gemini
|
|
||||||
///
|
|
||||||
/// 因此不需要在 URL 中添加应用前缀。
|
|
||||||
async fn takeover_live_configs(&self) -> Result<(), String> {
|
|
||||||
let config = self
|
|
||||||
.db
|
|
||||||
.get_proxy_config()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
|
||||||
|
|
||||||
let proxy_url = format!("http://{}:{}", config.listen_address, config.listen_port);
|
|
||||||
|
|
||||||
// Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)
|
|
||||||
if let Ok(mut live_config) = self.read_claude_live() {
|
|
||||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
|
||||||
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(&proxy_url));
|
|
||||||
// 使用占位符,避免 Claude Code 显示缺少 key 的警告
|
|
||||||
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!("PROXY_MANAGED"));
|
|
||||||
} else {
|
|
||||||
live_config["env"] = json!({
|
|
||||||
"ANTHROPIC_BASE_URL": &proxy_url,
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.write_claude_live(&live_config)?;
|
|
||||||
log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)
|
|
||||||
if let Ok(mut live_config) = self.read_codex_live() {
|
|
||||||
// 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)
|
|
||||||
if let Some(auth) = live_config.get_mut("auth").and_then(|v| v.as_object_mut()) {
|
|
||||||
auth.insert("OPENAI_API_KEY".to_string(), json!("PROXY_MANAGED"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 修改 config.toml 中的 base_url
|
|
||||||
let config_str = live_config
|
|
||||||
.get("config")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let updated_config = Self::update_toml_base_url(config_str, &proxy_url);
|
|
||||||
live_config["config"] = json!(updated_config);
|
|
||||||
|
|
||||||
self.write_codex_live(&live_config)?;
|
|
||||||
log::info!("Codex Live 配置已接管,代理地址: {proxy_url}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)
|
|
||||||
if let Ok(mut live_config) = self.read_gemini_live() {
|
|
||||||
if let Some(env) = live_config.get_mut("env").and_then(|v| v.as_object_mut()) {
|
|
||||||
env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(&proxy_url));
|
|
||||||
// 使用占位符,避免显示缺少 key 的警告
|
|
||||||
env.insert("GEMINI_API_KEY".to_string(), json!("PROXY_MANAGED"));
|
|
||||||
} else {
|
|
||||||
live_config["env"] = json!({
|
|
||||||
"GOOGLE_GEMINI_BASE_URL": &proxy_url,
|
|
||||||
"GEMINI_API_KEY": "PROXY_MANAGED"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.write_gemini_live(&live_config)?;
|
|
||||||
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 恢复原始 Live 配置
|
|
||||||
async fn restore_live_configs(&self) -> Result<(), String> {
|
|
||||||
// Claude
|
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
|
||||||
.map_err(|e| format!("解析 Claude 备份失败: {e}"))?;
|
|
||||||
self.write_claude_live(&config)?;
|
|
||||||
log::info!("Claude Live 配置已恢复");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex
|
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("codex").await {
|
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
|
||||||
.map_err(|e| format!("解析 Codex 备份失败: {e}"))?;
|
|
||||||
self.write_codex_live(&config)?;
|
|
||||||
log::info!("Codex Live 配置已恢复");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini
|
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await {
|
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
|
||||||
.map_err(|e| format!("解析 Gemini 备份失败: {e}"))?;
|
|
||||||
self.write_gemini_live(&config)?;
|
|
||||||
log::info!("Gemini Live 配置已恢复");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否处于 Live 接管模式
|
|
||||||
pub async fn is_takeover_active(&self) -> Result<bool, String> {
|
|
||||||
self.db
|
|
||||||
.is_live_takeover_active()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("检查接管状态失败: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从异常退出中恢复(启动时调用)
|
|
||||||
///
|
|
||||||
/// 检测到 live_takeover_active=true 但代理未运行时调用此方法。
|
|
||||||
/// 会恢复 Live 配置、清除接管标志、删除备份。
|
|
||||||
pub async fn recover_from_crash(&self) -> Result<(), String> {
|
|
||||||
// 1. 恢复 Live 配置
|
|
||||||
self.restore_live_configs().await?;
|
|
||||||
|
|
||||||
// 2. 清除接管标志
|
|
||||||
self.db
|
|
||||||
.set_live_takeover_active(false)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("清除接管状态失败: {e}"))?;
|
|
||||||
|
|
||||||
// 3. 删除备份
|
|
||||||
self.db
|
|
||||||
.delete_all_live_backups()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("删除备份失败: {e}"))?;
|
|
||||||
|
|
||||||
log::info!("已从异常退出中恢复 Live 配置");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从供应商配置更新 Live 备份(用于代理模式下的热切换)
|
|
||||||
///
|
|
||||||
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
|
|
||||||
/// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。
|
|
||||||
pub async fn update_live_backup_from_provider(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let backup_json = match app_type {
|
|
||||||
"claude" => {
|
|
||||||
// Claude: settings_config 直接作为备份
|
|
||||||
serde_json::to_string(&provider.settings_config)
|
|
||||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
|
|
||||||
}
|
|
||||||
"codex" => {
|
|
||||||
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
|
|
||||||
serde_json::to_string(&provider.settings_config)
|
|
||||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
|
|
||||||
}
|
|
||||||
"gemini" => {
|
|
||||||
// Gemini: 只提取 env 字段(与原始备份格式一致)
|
|
||||||
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
|
|
||||||
let env_backup = if let Some(env) = provider.settings_config.get("env") {
|
|
||||||
json!({ "env": env })
|
|
||||||
} else {
|
|
||||||
json!({ "env": {} })
|
|
||||||
};
|
|
||||||
serde_json::to_string(&env_backup)
|
|
||||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
|
|
||||||
}
|
|
||||||
_ => return Err(format!("未知的应用类型: {app_type}")),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.db
|
|
||||||
.save_live_backup(app_type, &backup_json)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("更新 {app_type} 备份失败: {e}"))?;
|
|
||||||
|
|
||||||
log::info!("已更新 {app_type} Live 备份(热切换)");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 代理模式下切换供应商(热切换,不写 Live)
|
|
||||||
pub async fn switch_proxy_target(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// 更新数据库中的 is_current 标记
|
|
||||||
let app_type_enum =
|
|
||||||
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
|
|
||||||
|
|
||||||
self.db
|
|
||||||
.set_current_provider(app_type_enum.as_str(), provider_id)
|
|
||||||
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
|
|
||||||
|
|
||||||
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Live 配置读写辅助方法 ====================
|
|
||||||
|
|
||||||
/// 更新 TOML 字符串中的 base_url
|
|
||||||
fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {
|
|
||||||
use toml_edit::DocumentMut;
|
|
||||||
|
|
||||||
let mut doc = toml_str
|
|
||||||
.parse::<DocumentMut>()
|
|
||||||
.unwrap_or_else(|_| DocumentMut::new());
|
|
||||||
|
|
||||||
doc["base_url"] = toml_edit::value(new_url);
|
|
||||||
|
|
||||||
doc.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_claude_live(&self) -> Result<Value, String> {
|
|
||||||
let path = get_claude_settings_path();
|
|
||||||
if !path.exists() {
|
|
||||||
return Err("Claude 配置文件不存在".to_string());
|
|
||||||
}
|
|
||||||
read_json_file(&path).map_err(|e| format!("读取 Claude 配置失败: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_claude_live(&self, config: &Value) -> Result<(), String> {
|
|
||||||
let path = get_claude_settings_path();
|
|
||||||
write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_codex_live(&self) -> Result<Value, String> {
|
|
||||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
|
||||||
|
|
||||||
let auth_path = get_codex_auth_path();
|
|
||||||
if !auth_path.exists() {
|
|
||||||
return Err("Codex auth.json 不存在".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let auth: Value =
|
|
||||||
read_json_file(&auth_path).map_err(|e| format!("读取 Codex auth 失败: {e}"))?;
|
|
||||||
|
|
||||||
let config_path = get_codex_config_path();
|
|
||||||
let config_str = if config_path.exists() {
|
|
||||||
std::fs::read_to_string(&config_path)
|
|
||||||
.map_err(|e| format!("读取 Codex config 失败: {e}"))?
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"auth": auth,
|
|
||||||
"config": config_str
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_codex_live(&self, config: &Value) -> Result<(), String> {
|
|
||||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
|
||||||
|
|
||||||
if let Some(auth) = config.get("auth") {
|
|
||||||
let auth_path = get_codex_auth_path();
|
|
||||||
write_json_file(&auth_path, auth).map_err(|e| format!("写入 Codex auth 失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
|
|
||||||
let config_path = get_codex_config_path();
|
|
||||||
std::fs::write(&config_path, config_str)
|
|
||||||
.map_err(|e| format!("写入 Codex config 失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_gemini_live(&self) -> Result<Value, String> {
|
|
||||||
use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};
|
|
||||||
|
|
||||||
let env_path = get_gemini_env_path();
|
|
||||||
if !env_path.exists() {
|
|
||||||
return Err("Gemini .env 文件不存在".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let env_map = read_gemini_env().map_err(|e| format!("读取 Gemini env 失败: {e}"))?;
|
|
||||||
Ok(env_to_json(&env_map))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_gemini_live(&self, config: &Value) -> Result<(), String> {
|
|
||||||
use crate::gemini_config::{json_to_env, write_gemini_env_atomic};
|
|
||||||
|
|
||||||
let env_map = json_to_env(config).map_err(|e| format!("转换 Gemini 配置失败: {e}"))?;
|
|
||||||
write_gemini_env_atomic(&env_map).map_err(|e| format!("写入 Gemini env 失败: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 原有方法 ====================
|
|
||||||
|
|
||||||
/// 获取服务器状态
|
/// 获取服务器状态
|
||||||
pub async fn get_status(&self) -> Result<ProxyStatus, String> {
|
pub async fn get_status(&self) -> Result<ProxyStatus, String> {
|
||||||
if let Some(server) = self.server.read().await.as_ref() {
|
if let Some(server) = self.server.read().await.as_ref() {
|
||||||
@@ -636,10 +103,9 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
||||||
|
|
||||||
// 保存到数据库(保持 enabled 和 live_takeover_active 状态不变)
|
// 保存到数据库(保持 enabled 状态不变)
|
||||||
let mut new_config = config.clone();
|
let mut new_config = config.clone();
|
||||||
new_config.enabled = previous.enabled;
|
new_config.enabled = previous.enabled;
|
||||||
new_config.live_takeover_active = previous.live_takeover_active;
|
|
||||||
|
|
||||||
self.db
|
self.db
|
||||||
.update_proxy_config(new_config.clone())
|
.update_proxy_config(new_config.clone())
|
||||||
@@ -664,8 +130,7 @@ impl ProxyService {
|
|||||||
.map_err(|e| format!("重启前停止代理服务器失败: {e}"))?;
|
.map_err(|e| format!("重启前停止代理服务器失败: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let app_handle = self.app_handle.read().await.clone();
|
let new_server = ProxyServer::new(new_config, self.db.clone());
|
||||||
let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);
|
|
||||||
new_server
|
new_server
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
@@ -685,37 +150,4 @@ impl ProxyService {
|
|||||||
pub async fn is_running(&self) -> bool {
|
pub async fn is_running(&self) -> bool {
|
||||||
self.server.read().await.is_some()
|
self.server.read().await.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 热更新熔断器配置
|
|
||||||
///
|
|
||||||
/// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例
|
|
||||||
pub async fn update_circuit_breaker_configs(
|
|
||||||
&self,
|
|
||||||
config: crate::proxy::CircuitBreakerConfig,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if let Some(server) = self.server.read().await.as_ref() {
|
|
||||||
server.update_circuit_breaker_configs(config).await;
|
|
||||||
log::info!("已热更新运行中的熔断器配置");
|
|
||||||
} else {
|
|
||||||
log::debug!("代理服务器未运行,熔断器配置将在下次启动时生效");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置指定 Provider 的熔断器
|
|
||||||
///
|
|
||||||
/// 如果代理服务器正在运行,立即重置内存中的熔断器状态
|
|
||||||
pub async fn reset_provider_circuit_breaker(
|
|
||||||
&self,
|
|
||||||
provider_id: &str,
|
|
||||||
app_type: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if let Some(server) = self.server.read().await.as_ref() {
|
|
||||||
server
|
|
||||||
.reset_provider_circuit_breaker(provider_id, app_type)
|
|
||||||
.await;
|
|
||||||
log::info!("已重置 Provider {provider_id} (app: {app_type}) 的熔断器");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::format_skill_error;
|
use crate::error::format_skill_error;
|
||||||
|
|
||||||
/// 技能对象
|
/// 技能对象
|
||||||
@@ -107,16 +106,11 @@ pub struct SkillMetadata {
|
|||||||
pub struct SkillService {
|
pub struct SkillService {
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
install_dir: PathBuf,
|
install_dir: PathBuf,
|
||||||
app_type: AppType,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillService {
|
impl SkillService {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
Self::new_for_app(AppType::Claude)
|
let install_dir = Self::get_install_dir()?;
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_for_app(app_type: AppType) -> Result<Self> {
|
|
||||||
let install_dir = Self::get_install_dir_for_app(&app_type)?;
|
|
||||||
|
|
||||||
// 确保目录存在
|
// 确保目录存在
|
||||||
fs::create_dir_all(&install_dir)?;
|
fs::create_dir_all(&install_dir)?;
|
||||||
@@ -128,38 +122,16 @@ impl SkillService {
|
|||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()?,
|
.build()?,
|
||||||
install_dir,
|
install_dir,
|
||||||
app_type,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_install_dir_for_app(app_type: &AppType) -> Result<PathBuf> {
|
fn get_install_dir() -> Result<PathBuf> {
|
||||||
let home = dirs::home_dir().context(format_skill_error(
|
let home = dirs::home_dir().context(format_skill_error(
|
||||||
"GET_HOME_DIR_FAILED",
|
"GET_HOME_DIR_FAILED",
|
||||||
&[],
|
&[],
|
||||||
Some("checkPermission"),
|
Some("checkPermission"),
|
||||||
))?;
|
))?;
|
||||||
|
Ok(home.join(".claude").join("skills"))
|
||||||
let dir = match app_type {
|
|
||||||
AppType::Claude => home.join(".claude").join("skills"),
|
|
||||||
AppType::Codex => {
|
|
||||||
// 检查是否有自定义 Codex 配置目录
|
|
||||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
|
||||||
custom.join("skills")
|
|
||||||
} else {
|
|
||||||
home.join(".codex").join("skills")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AppType::Gemini => {
|
|
||||||
// 为 Gemini 预留,暂时使用默认路径
|
|
||||||
home.join(".gemini").join("skills")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn app_type(&self) -> &AppType {
|
|
||||||
&self.app_type
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,20 +316,9 @@ impl SkillService {
|
|||||||
let directory = &local_skill.directory;
|
let directory = &local_skill.directory;
|
||||||
|
|
||||||
// 更新已安装状态(匹配远程技能)
|
// 更新已安装状态(匹配远程技能)
|
||||||
// 使用目录最后一段进行比较,因为安装时只使用最后一段作为目录名
|
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
let local_install_name = Path::new(directory)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| directory.clone());
|
|
||||||
|
|
||||||
for skill in skills.iter_mut() {
|
for skill in skills.iter_mut() {
|
||||||
let remote_install_name = Path::new(&skill.directory)
|
if skill.directory.eq_ignore_ascii_case(directory) {
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| skill.directory.clone());
|
|
||||||
|
|
||||||
if remote_install_name.eq_ignore_ascii_case(&local_install_name) {
|
|
||||||
skill.installed = true;
|
skill.installed = true;
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
@@ -556,14 +517,7 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
|
||||||
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
|
||||||
// 使用技能目录的最后一段作为安装目录名,避免嵌套路径问题
|
let dest = self.install_dir.join(&directory);
|
||||||
// 例如: "skills/codex" -> "codex"
|
|
||||||
let install_name = Path::new(&directory)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| directory.clone());
|
|
||||||
|
|
||||||
let dest = self.install_dir.join(&install_name);
|
|
||||||
|
|
||||||
// 若目标目录已存在,则视为已安装,避免重复下载
|
// 若目标目录已存在,则视为已安装,避免重复下载
|
||||||
if dest.exists() {
|
if dest.exists() {
|
||||||
@@ -635,13 +589,7 @@ impl SkillService {
|
|||||||
|
|
||||||
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
|
||||||
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
|
||||||
// 使用技能目录的最后一段作为安装目录名,与 install_skill 保持一致
|
let dest = self.install_dir.join(&directory);
|
||||||
let install_name = Path::new(&directory)
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| directory.clone());
|
|
||||||
|
|
||||||
let dest = self.install_dir.join(&install_name);
|
|
||||||
|
|
||||||
if dest.exists() {
|
if dest.exists() {
|
||||||
fs::remove_dir_all(&dest)?;
|
fs::remove_dir_all(&dest)?;
|
||||||
|
|||||||
@@ -31,51 +31,40 @@ impl SpeedtestService {
|
|||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut results: Vec<Option<EndpointLatency>> = vec![None; urls.len()];
|
|
||||||
let mut valid_targets = Vec::new();
|
|
||||||
|
|
||||||
for (idx, raw_url) in urls.into_iter().enumerate() {
|
|
||||||
let trimmed = raw_url.trim().to_string();
|
|
||||||
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
results[idx] = Some(EndpointLatency {
|
|
||||||
url: raw_url,
|
|
||||||
latency: None,
|
|
||||||
status: None,
|
|
||||||
error: Some("URL 不能为空".to_string()),
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match Url::parse(&trimmed) {
|
|
||||||
Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)),
|
|
||||||
Err(err) => {
|
|
||||||
results[idx] = Some(EndpointLatency {
|
|
||||||
url: trimmed,
|
|
||||||
latency: None,
|
|
||||||
status: None,
|
|
||||||
error: Some(format!("URL 无效: {err}")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if valid_targets.is_empty() {
|
|
||||||
return Ok(results.into_iter().flatten().collect::<Vec<_>>());
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout = Self::sanitize_timeout(timeout_secs);
|
let timeout = Self::sanitize_timeout(timeout_secs);
|
||||||
let client = Self::build_client(timeout)?;
|
let client = Self::build_client(timeout)?;
|
||||||
|
|
||||||
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
|
let tasks = urls.into_iter().map(|raw_url| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
async move {
|
async move {
|
||||||
|
let trimmed = raw_url.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return EndpointLatency {
|
||||||
|
url: raw_url,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some("URL 不能为空".to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed_url = match Url::parse(&trimmed) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(err) => {
|
||||||
|
return EndpointLatency {
|
||||||
|
url: trimmed,
|
||||||
|
latency: None,
|
||||||
|
status: None,
|
||||||
|
error: Some(format!("URL 无效: {err}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
|
||||||
let _ = client.get(parsed_url.clone()).send().await;
|
let _ = client.get(parsed_url.clone()).send().await;
|
||||||
|
|
||||||
// 第二次请求开始计时,并将其作为结果返回。
|
// 第二次请求开始计时,并将其作为结果返回。
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let latency = match client.get(parsed_url).send().await {
|
match client.get(parsed_url).send().await {
|
||||||
Ok(resp) => EndpointLatency {
|
Ok(resp) => EndpointLatency {
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
latency: Some(start.elapsed().as_millis()),
|
latency: Some(start.elapsed().as_millis()),
|
||||||
@@ -99,17 +88,11 @@ impl SpeedtestService {
|
|||||||
error: Some(error_message),
|
error: Some(error_message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
(idx, latency)
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (idx, latency) in join_all(tasks).await {
|
Ok(join_all(tasks).await)
|
||||||
results[idx] = Some(latency);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results.into_iter().flatten().collect::<Vec<_>>())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
|
||||||
|
|||||||
@@ -1,436 +0,0 @@
|
|||||||
//! 流式健康检查服务
|
|
||||||
//!
|
|
||||||
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
|
|
||||||
|
|
||||||
use futures::StreamExt;
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::json;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::proxy::providers::{get_adapter, AuthInfo};
|
|
||||||
|
|
||||||
/// 健康状态枚举
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum HealthStatus {
|
|
||||||
Operational,
|
|
||||||
Degraded,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 流式检查配置
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct StreamCheckConfig {
|
|
||||||
pub timeout_secs: u64,
|
|
||||||
pub max_retries: u32,
|
|
||||||
pub degraded_threshold_ms: u64,
|
|
||||||
/// Claude 测试模型
|
|
||||||
pub claude_model: String,
|
|
||||||
/// Codex 测试模型
|
|
||||||
pub codex_model: String,
|
|
||||||
/// Gemini 测试模型
|
|
||||||
pub gemini_model: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StreamCheckConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
timeout_secs: 45,
|
|
||||||
max_retries: 2,
|
|
||||||
degraded_threshold_ms: 6000,
|
|
||||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
|
||||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
|
||||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 流式检查结果
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct StreamCheckResult {
|
|
||||||
pub status: HealthStatus,
|
|
||||||
pub success: bool,
|
|
||||||
pub message: String,
|
|
||||||
pub response_time_ms: Option<u64>,
|
|
||||||
pub http_status: Option<u16>,
|
|
||||||
pub model_used: String,
|
|
||||||
pub tested_at: i64,
|
|
||||||
pub retry_count: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 流式健康检查服务
|
|
||||||
pub struct StreamCheckService;
|
|
||||||
|
|
||||||
impl StreamCheckService {
|
|
||||||
/// 执行流式健康检查(带重试)
|
|
||||||
pub async fn check_with_retry(
|
|
||||||
app_type: &AppType,
|
|
||||||
provider: &Provider,
|
|
||||||
config: &StreamCheckConfig,
|
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
|
||||||
let mut last_result = None;
|
|
||||||
|
|
||||||
for attempt in 0..=config.max_retries {
|
|
||||||
let result = Self::check_once(app_type, provider, config).await;
|
|
||||||
|
|
||||||
match &result {
|
|
||||||
Ok(r) if r.success => {
|
|
||||||
return Ok(StreamCheckResult {
|
|
||||||
retry_count: attempt,
|
|
||||||
..r.clone()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(r) => {
|
|
||||||
// 失败但非异常,判断是否重试
|
|
||||||
if Self::should_retry(&r.message) && attempt < config.max_retries {
|
|
||||||
last_result = Some(r.clone());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Ok(StreamCheckResult {
|
|
||||||
retry_count: attempt,
|
|
||||||
..r.clone()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if Self::should_retry(&e.to_string()) && attempt < config.max_retries {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Err(AppError::Message(e.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(last_result.unwrap_or_else(|| StreamCheckResult {
|
|
||||||
status: HealthStatus::Failed,
|
|
||||||
success: false,
|
|
||||||
message: "检查失败".to_string(),
|
|
||||||
response_time_ms: None,
|
|
||||||
http_status: None,
|
|
||||||
model_used: String::new(),
|
|
||||||
tested_at: chrono::Utc::now().timestamp(),
|
|
||||||
retry_count: config.max_retries,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 单次流式检查
|
|
||||||
async fn check_once(
|
|
||||||
app_type: &AppType,
|
|
||||||
provider: &Provider,
|
|
||||||
config: &StreamCheckConfig,
|
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
|
||||||
let start = Instant::now();
|
|
||||||
let adapter = get_adapter(app_type);
|
|
||||||
|
|
||||||
let base_url = adapter
|
|
||||||
.extract_base_url(provider)
|
|
||||||
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
|
|
||||||
|
|
||||||
let auth = adapter
|
|
||||||
.extract_auth(provider)
|
|
||||||
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
|
|
||||||
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(Duration::from_secs(config.timeout_secs))
|
|
||||||
.user_agent("cc-switch/1.0")
|
|
||||||
.build()
|
|
||||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
|
||||||
|
|
||||||
let result = match app_type {
|
|
||||||
AppType::Claude => {
|
|
||||||
Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await
|
|
||||||
}
|
|
||||||
AppType::Codex => {
|
|
||||||
Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await
|
|
||||||
}
|
|
||||||
AppType::Gemini => {
|
|
||||||
Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let response_time = start.elapsed().as_millis() as u64;
|
|
||||||
let tested_at = chrono::Utc::now().timestamp();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok((status_code, model)) => {
|
|
||||||
let health_status =
|
|
||||||
Self::determine_status(response_time, config.degraded_threshold_ms);
|
|
||||||
Ok(StreamCheckResult {
|
|
||||||
status: health_status,
|
|
||||||
success: true,
|
|
||||||
message: "检查成功".to_string(),
|
|
||||||
response_time_ms: Some(response_time),
|
|
||||||
http_status: Some(status_code),
|
|
||||||
model_used: model,
|
|
||||||
tested_at,
|
|
||||||
retry_count: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(e) => Ok(StreamCheckResult {
|
|
||||||
status: HealthStatus::Failed,
|
|
||||||
success: false,
|
|
||||||
message: e.to_string(),
|
|
||||||
response_time_ms: Some(response_time),
|
|
||||||
http_status: None,
|
|
||||||
model_used: String::new(),
|
|
||||||
tested_at,
|
|
||||||
retry_count: 0,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Claude 流式检查
|
|
||||||
async fn check_claude_stream(
|
|
||||||
client: &Client,
|
|
||||||
base_url: &str,
|
|
||||||
auth: &AuthInfo,
|
|
||||||
model: &str,
|
|
||||||
) -> Result<(u16, String), AppError> {
|
|
||||||
let base = base_url.trim_end_matches('/');
|
|
||||||
let url = if base.ends_with("/v1") {
|
|
||||||
format!("{base}/messages")
|
|
||||||
} else {
|
|
||||||
format!("{base}/v1/messages")
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = json!({
|
|
||||||
"model": model,
|
|
||||||
"max_tokens": 1,
|
|
||||||
"messages": [{ "role": "user", "content": "hi" }],
|
|
||||||
"stream": true
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(&url)
|
|
||||||
.header("x-api-key", &auth.api_key)
|
|
||||||
.header("anthropic-version", "2023-06-01")
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(Self::map_request_error)?;
|
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流式读取:只需首个 chunk
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
if let Some(chunk) = stream.next().await {
|
|
||||||
match chunk {
|
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Codex 流式检查
|
|
||||||
async fn check_codex_stream(
|
|
||||||
client: &Client,
|
|
||||||
base_url: &str,
|
|
||||||
auth: &AuthInfo,
|
|
||||||
model: &str,
|
|
||||||
) -> Result<(u16, String), AppError> {
|
|
||||||
let base = base_url.trim_end_matches('/');
|
|
||||||
let url = if base.ends_with("/v1") {
|
|
||||||
format!("{base}/chat/completions")
|
|
||||||
} else {
|
|
||||||
format!("{base}/v1/chat/completions")
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
|
||||||
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model);
|
|
||||||
|
|
||||||
let mut body = json!({
|
|
||||||
"model": actual_model,
|
|
||||||
"messages": [
|
|
||||||
{ "role": "system", "content": "" },
|
|
||||||
{ "role": "assistant", "content": "" },
|
|
||||||
{ "role": "user", "content": "hi" }
|
|
||||||
],
|
|
||||||
"max_tokens": 1,
|
|
||||||
"temperature": 0,
|
|
||||||
"stream": true
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果是推理模型,添加 reasoning_effort
|
|
||||||
if let Some(effort) = reasoning_effort {
|
|
||||||
body["reasoning_effort"] = json!(effort);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(&url)
|
|
||||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(Self::map_request_error)?;
|
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
if let Some(chunk) = stream.next().await {
|
|
||||||
match chunk {
|
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gemini 流式检查
|
|
||||||
async fn check_gemini_stream(
|
|
||||||
client: &Client,
|
|
||||||
base_url: &str,
|
|
||||||
auth: &AuthInfo,
|
|
||||||
model: &str,
|
|
||||||
) -> Result<(u16, String), AppError> {
|
|
||||||
let base = base_url.trim_end_matches('/');
|
|
||||||
let url = format!("{base}/v1/chat/completions");
|
|
||||||
|
|
||||||
let body = json!({
|
|
||||||
"model": model,
|
|
||||||
"messages": [{ "role": "user", "content": "hi" }],
|
|
||||||
"max_tokens": 1,
|
|
||||||
"temperature": 0,
|
|
||||||
"stream": true
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(&url)
|
|
||||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(Self::map_request_error)?;
|
|
||||||
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
if let Some(chunk) = stream.next().await {
|
|
||||||
match chunk {
|
|
||||||
Ok(_) => Ok((status, model.to_string())),
|
|
||||||
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(AppError::Message("未收到响应数据".to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn determine_status(latency_ms: u64, threshold: u64) -> HealthStatus {
|
|
||||||
if latency_ms <= threshold {
|
|
||||||
HealthStatus::Operational
|
|
||||||
} else {
|
|
||||||
HealthStatus::Degraded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
|
|
||||||
/// 返回 (实际模型名, Option<推理等级>)
|
|
||||||
fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
|
|
||||||
// 查找 @ 或 # 分隔符
|
|
||||||
if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
|
|
||||||
let actual_model = model[..pos].to_string();
|
|
||||||
let effort = model[pos + 1..].to_string();
|
|
||||||
if !effort.is_empty() {
|
|
||||||
return (actual_model, Some(effort));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(model.to_string(), None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_retry(msg: &str) -> bool {
|
|
||||||
let lower = msg.to_lowercase();
|
|
||||||
lower.contains("timeout")
|
|
||||||
|| lower.contains("abort")
|
|
||||||
|| lower.contains("中断")
|
|
||||||
|| lower.contains("超时")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_request_error(e: reqwest::Error) -> AppError {
|
|
||||||
if e.is_timeout() {
|
|
||||||
AppError::Message("请求超时".to_string())
|
|
||||||
} else if e.is_connect() {
|
|
||||||
AppError::Message(format!("连接失败: {e}"))
|
|
||||||
} else {
|
|
||||||
AppError::Message(e.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_determine_status() {
|
|
||||||
assert_eq!(
|
|
||||||
StreamCheckService::determine_status(3000, 6000),
|
|
||||||
HealthStatus::Operational
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
StreamCheckService::determine_status(6000, 6000),
|
|
||||||
HealthStatus::Operational
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
StreamCheckService::determine_status(6001, 6000),
|
|
||||||
HealthStatus::Degraded
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_should_retry() {
|
|
||||||
assert!(StreamCheckService::should_retry("请求超时"));
|
|
||||||
assert!(StreamCheckService::should_retry("request timeout"));
|
|
||||||
assert!(!StreamCheckService::should_retry("API Key 无效"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_default_config() {
|
|
||||||
let config = StreamCheckConfig::default();
|
|
||||||
assert_eq!(config.timeout_secs, 45);
|
|
||||||
assert_eq!(config.max_retries, 2);
|
|
||||||
assert_eq!(config.degraded_threshold_ms, 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_model_with_effort() {
|
|
||||||
// 带 @ 分隔符
|
|
||||||
let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-5.1-codex@low");
|
|
||||||
assert_eq!(model, "gpt-5.1-codex");
|
|
||||||
assert_eq!(effort, Some("low".to_string()));
|
|
||||||
|
|
||||||
// 带 # 分隔符
|
|
||||||
let (model, effort) = StreamCheckService::parse_model_with_effort("o1-preview#high");
|
|
||||||
assert_eq!(model, "o1-preview");
|
|
||||||
assert_eq!(effort, Some("high".to_string()));
|
|
||||||
|
|
||||||
// 无分隔符
|
|
||||||
let (model, effort) = StreamCheckService::parse_model_with_effort("gpt-4o-mini");
|
|
||||||
assert_eq!(model, "gpt-4o-mini");
|
|
||||||
assert_eq!(effort, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -652,6 +652,56 @@ impl Database {
|
|||||||
monthly_exceeded,
|
monthly_exceeded,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新每日统计聚合
|
||||||
|
///
|
||||||
|
/// 在请求完成后调用,更新 usage_daily_stats 表
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn update_daily_stats(
|
||||||
|
&self,
|
||||||
|
provider_id: &str,
|
||||||
|
app_type: &str,
|
||||||
|
model: &str,
|
||||||
|
input_tokens: u32,
|
||||||
|
output_tokens: u32,
|
||||||
|
total_cost: &str,
|
||||||
|
is_success: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let date = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
// 使用 UPSERT 更新或插入统计
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO usage_daily_stats (
|
||||||
|
date, provider_id, app_type, model,
|
||||||
|
request_count, total_input_tokens, total_output_tokens,
|
||||||
|
total_cost_usd, success_count, error_count
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9)
|
||||||
|
ON CONFLICT(date, provider_id, app_type, model) DO UPDATE SET
|
||||||
|
request_count = request_count + 1,
|
||||||
|
total_input_tokens = total_input_tokens + ?5,
|
||||||
|
total_output_tokens = total_output_tokens + ?6,
|
||||||
|
total_cost_usd = CAST(
|
||||||
|
CAST(total_cost_usd AS REAL) + CAST(?7 AS REAL) AS TEXT
|
||||||
|
),
|
||||||
|
success_count = success_count + ?8,
|
||||||
|
error_count = error_count + ?9",
|
||||||
|
params![
|
||||||
|
date,
|
||||||
|
provider_id,
|
||||||
|
app_type,
|
||||||
|
model,
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
total_cost,
|
||||||
|
if is_success { 1 } else { 0 },
|
||||||
|
if is_success { 0 } else { 1 },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("更新每日统计失败: {e}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider 限额状态
|
/// Provider 限额状态
|
||||||
|
|||||||
+18
-492
@@ -3,7 +3,6 @@ use rquickjs::{Context, Function, Runtime};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use url::{Host, Url};
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -16,14 +15,20 @@ pub async fn execute_usage_script(
|
|||||||
access_token: Option<&str>,
|
access_token: Option<&str>,
|
||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
) -> Result<Value, AppError> {
|
) -> Result<Value, AppError> {
|
||||||
// 1. 替换模板变量,避免泄露敏感信息
|
// 1. 替换变量
|
||||||
let script_with_vars =
|
let mut replaced = script_code
|
||||||
build_script_with_vars(script_code, api_key, base_url, access_token, user_id);
|
.replace("{{apiKey}}", api_key)
|
||||||
|
.replace("{{baseUrl}}", base_url);
|
||||||
|
|
||||||
// 2. 验证 base_url 的安全性
|
// 替换 accessToken 和 userId
|
||||||
validate_base_url(base_url)?;
|
if let Some(token) = access_token {
|
||||||
|
replaced = replaced.replace("{{accessToken}}", token);
|
||||||
|
}
|
||||||
|
if let Some(uid) = user_id {
|
||||||
|
replaced = replaced.replace("{{userId}}", uid);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
|
||||||
let request_config = {
|
let request_config = {
|
||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -42,7 +47,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 执行用户代码,获取配置对象
|
// 执行用户代码,获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_parse_failed",
|
"usage_script.config_parse_failed",
|
||||||
format!("解析配置失败: {e}"),
|
format!("解析配置失败: {e}"),
|
||||||
@@ -89,7 +94,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 4. 解析 request 配置
|
// 3. 解析 request 配置
|
||||||
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
let request: RequestConfig = serde_json::from_str(&request_config).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.request_format_invalid",
|
"usage_script.request_format_invalid",
|
||||||
@@ -98,13 +103,10 @@ pub async fn execute_usage_script(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 5. 验证请求 URL 是否安全(防止 SSRF)
|
// 4. 发送 HTTP 请求
|
||||||
validate_request_url(&request.url, base_url)?;
|
|
||||||
|
|
||||||
// 6. 发送 HTTP 请求
|
|
||||||
let response_data = send_http_request(&request, timeout_secs).await?;
|
let response_data = send_http_request(&request, timeout_secs).await?;
|
||||||
|
|
||||||
// 7. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
// 5. 在独立作用域中执行 extractor(确保 Runtime/Context 在函数结束前释放)
|
||||||
let result: Value = {
|
let result: Value = {
|
||||||
let runtime = Runtime::new().map_err(|e| {
|
let runtime = Runtime::new().map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
@@ -123,7 +125,7 @@ pub async fn execute_usage_script(
|
|||||||
|
|
||||||
context.with(|ctx| {
|
context.with(|ctx| {
|
||||||
// 重新 eval 获取配置对象
|
// 重新 eval 获取配置对象
|
||||||
let config: rquickjs::Object = ctx.eval(script_with_vars.clone()).map_err(|e| {
|
let config: rquickjs::Object = ctx.eval(replaced.clone()).map_err(|e| {
|
||||||
AppError::localized(
|
AppError::localized(
|
||||||
"usage_script.config_reparse_failed",
|
"usage_script.config_reparse_failed",
|
||||||
format!("重新解析配置失败: {e}"),
|
format!("重新解析配置失败: {e}"),
|
||||||
@@ -196,7 +198,7 @@ pub async fn execute_usage_script(
|
|||||||
})?
|
})?
|
||||||
}; // Runtime 和 Context 在这里被 drop
|
}; // Runtime 和 Context 在这里被 drop
|
||||||
|
|
||||||
// 8. 验证返回值格式
|
// 6. 验证返回值格式
|
||||||
validate_result(&result)?;
|
validate_result(&result)?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -392,479 +394,3 @@ fn validate_single_usage(result: &Value) -> Result<(), AppError> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建替换变量后的脚本,保持与旧版脚本的兼容性
|
|
||||||
fn build_script_with_vars(
|
|
||||||
script_code: &str,
|
|
||||||
api_key: &str,
|
|
||||||
base_url: &str,
|
|
||||||
access_token: Option<&str>,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let mut replaced = script_code
|
|
||||||
.replace("{{apiKey}}", api_key)
|
|
||||||
.replace("{{baseUrl}}", base_url);
|
|
||||||
|
|
||||||
if let Some(token) = access_token {
|
|
||||||
replaced = replaced.replace("{{accessToken}}", token);
|
|
||||||
}
|
|
||||||
if let Some(uid) = user_id {
|
|
||||||
replaced = replaced.replace("{{userId}}", uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
replaced
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 验证 base_url 的基本安全性
|
|
||||||
fn validate_base_url(base_url: &str) -> Result<(), AppError> {
|
|
||||||
if base_url.is_empty() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_empty",
|
|
||||||
"base_url 不能为空",
|
|
||||||
"base_url cannot be empty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 URL
|
|
||||||
let parsed_url = Url::parse(base_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_invalid",
|
|
||||||
format!("无效的 base_url: {e}"),
|
|
||||||
format!("Invalid base_url: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let is_loopback = is_loopback_host(&parsed_url);
|
|
||||||
|
|
||||||
// 必须是 HTTPS(允许 localhost 用于开发)
|
|
||||||
if parsed_url.scheme() != "https" && !is_loopback {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_https_required",
|
|
||||||
"base_url 必须使用 HTTPS 协议(localhost 除外)",
|
|
||||||
"base_url must use HTTPS (localhost allowed)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查主机名格式有效性
|
|
||||||
let hostname = parsed_url.host_str().ok_or_else(|| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_hostname_missing",
|
|
||||||
"base_url 必须包含有效的主机名",
|
|
||||||
"base_url must include a valid hostname",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 基本的主机名格式检查
|
|
||||||
if hostname.is_empty() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_hostname_empty",
|
|
||||||
"base_url 主机名不能为空",
|
|
||||||
"base_url hostname cannot be empty",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
|
|
||||||
if is_suspicious_hostname(hostname) {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.base_url_suspicious",
|
|
||||||
"base_url 包含可疑的主机名",
|
|
||||||
"base_url contains a suspicious hostname",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 验证请求 URL 是否安全(防止 SSRF)
|
|
||||||
fn validate_request_url(request_url: &str, base_url: &str) -> Result<(), AppError> {
|
|
||||||
// 解析请求 URL
|
|
||||||
let parsed_request = Url::parse(request_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.request_url_invalid",
|
|
||||||
format!("无效的请求 URL: {e}"),
|
|
||||||
format!("Invalid request URL: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 解析 base URL
|
|
||||||
let parsed_base = Url::parse(base_url).map_err(|e| {
|
|
||||||
AppError::localized(
|
|
||||||
"usage_script.base_url_invalid",
|
|
||||||
format!("无效的 base_url: {e}"),
|
|
||||||
format!("Invalid base_url: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let is_request_loopback = is_loopback_host(&parsed_request);
|
|
||||||
|
|
||||||
// 必须使用 HTTPS(允许 localhost 用于开发)
|
|
||||||
if parsed_request.scheme() != "https" && !is_request_loopback {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_https_required",
|
|
||||||
"请求 URL 必须使用 HTTPS 协议(localhost 除外)",
|
|
||||||
"Request URL must use HTTPS (localhost allowed)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 核心安全检查:必须与 base_url 同源(相同域名和端口)
|
|
||||||
if parsed_request.host_str() != parsed_base.host_str() {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_host_mismatch",
|
|
||||||
format!(
|
|
||||||
"请求域名 {} 与 base_url 域名 {} 不匹配(必须是同源请求)",
|
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
|
||||||
),
|
|
||||||
format!(
|
|
||||||
"Request host {} must match base_url host {} (same-origin required)",
|
|
||||||
parsed_request.host_str().unwrap_or("unknown"),
|
|
||||||
parsed_base.host_str().unwrap_or("unknown")
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查端口是否匹配(考虑默认端口)
|
|
||||||
// 使用 port_or_known_default() 会自动处理默认端口(http->80, https->443)
|
|
||||||
match (
|
|
||||||
parsed_request.port_or_known_default(),
|
|
||||||
parsed_base.port_or_known_default(),
|
|
||||||
) {
|
|
||||||
(Some(request_port), Some(base_port)) if request_port == base_port => {
|
|
||||||
// 端口匹配,继续执行
|
|
||||||
}
|
|
||||||
(Some(request_port), Some(base_port)) => {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_port_mismatch",
|
|
||||||
format!("请求端口 {request_port} 必须与 base_url 端口 {base_port} 匹配"),
|
|
||||||
format!("Request port {request_port} must match base_url port {base_port}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// 理论上不会发生,因为 port_or_known_default() 应该总是返回 Some
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.request_port_unknown",
|
|
||||||
"无法确定端口号",
|
|
||||||
"Unable to determine port number",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
|
|
||||||
if let Some(host) = parsed_request.host_str() {
|
|
||||||
let base_host = parsed_base.host_str().unwrap_or("");
|
|
||||||
|
|
||||||
// 如果 base_url 不是私有地址,则禁止访问私有IP
|
|
||||||
if !is_private_ip(base_host) && is_private_ip(host) {
|
|
||||||
return Err(AppError::localized(
|
|
||||||
"usage_script.private_ip_blocked",
|
|
||||||
"禁止访问私有 IP 地址",
|
|
||||||
"Access to private IP addresses is blocked",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否为私有 IP 地址
|
|
||||||
fn is_private_ip(host: &str) -> bool {
|
|
||||||
// localhost 检查
|
|
||||||
if host.eq_ignore_ascii_case("localhost") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试解析为IP地址
|
|
||||||
if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {
|
|
||||||
return is_private_ip_addr(ip_addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果不是IP地址,不是私有IP
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 使用标准库API检查IP地址是否为私有地址
|
|
||||||
fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
|
|
||||||
match ip {
|
|
||||||
std::net::IpAddr::V4(ipv4) => {
|
|
||||||
let octets = ipv4.octets();
|
|
||||||
|
|
||||||
// 0.0.0.0/8 (包括未指定地址)
|
|
||||||
if octets[0] == 0 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC1918 私有地址范围
|
|
||||||
// 10.0.0.0/8
|
|
||||||
if octets[0] == 10 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
|
||||||
if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 192.168.0.0/16
|
|
||||||
if octets[0] == 192 && octets[1] == 168 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他特殊地址
|
|
||||||
// 169.254.0.0/16 (链路本地地址)
|
|
||||||
if octets[0] == 169 && octets[1] == 254 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 127.0.0.0/8 (环回地址)
|
|
||||||
if octets[0] == 127 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
std::net::IpAddr::V6(ipv6) => {
|
|
||||||
// IPv6 私有地址检查 - 使用标准库方法
|
|
||||||
|
|
||||||
// ::1 (环回地址)
|
|
||||||
if ipv6.is_loopback() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 唯一本地地址 (fc00::/7)
|
|
||||||
// Rust 1.70+ 可以使用 ipv6.is_unique_local()
|
|
||||||
// 但为了兼容性,我们手动检查
|
|
||||||
let first_segment = ipv6.segments()[0];
|
|
||||||
if (first_segment & 0xfe00) == 0xfc00 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 链路本地地址 (fe80::/10)
|
|
||||||
if (first_segment & 0xffc0) == 0xfe80 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未指定地址 ::
|
|
||||||
if ipv6.is_unspecified() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查是否为可疑的主机名(只检查明显不安全的模式)
|
|
||||||
fn is_suspicious_hostname(hostname: &str) -> bool {
|
|
||||||
// 空主机名
|
|
||||||
if hostname.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查明显的主机名格式问题
|
|
||||||
if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
|
|
||||||
if hostname.parse::<std::net::IpAddr>().is_ok() {
|
|
||||||
// IP地址格式的,在这里不直接拒绝,让同源检查来处理
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否包含明显不当的字符
|
|
||||||
let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
|
|
||||||
if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断 URL 是否指向本机(localhost / loopback)
|
|
||||||
fn is_loopback_host(url: &Url) -> bool {
|
|
||||||
match url.host() {
|
|
||||||
Some(Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
|
|
||||||
Some(Host::Ipv4(ip)) => ip.is_loopback(),
|
|
||||||
Some(Host::Ipv6(ip)) => ip.is_loopback(),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_private_ip_validation() {
|
|
||||||
// 测试IPv4私网地址
|
|
||||||
|
|
||||||
// RFC1918私网地址 - 应该返回true
|
|
||||||
assert!(is_private_ip("10.0.0.1"));
|
|
||||||
assert!(is_private_ip("10.255.255.254"));
|
|
||||||
assert!(is_private_ip("172.16.0.1"));
|
|
||||||
assert!(is_private_ip("172.31.255.255"));
|
|
||||||
assert!(is_private_ip("192.168.0.1"));
|
|
||||||
assert!(is_private_ip("192.168.255.255"));
|
|
||||||
|
|
||||||
// 链路本地地址 - 应该返回true
|
|
||||||
assert!(is_private_ip("169.254.0.1"));
|
|
||||||
assert!(is_private_ip("169.254.255.255"));
|
|
||||||
|
|
||||||
// 环回地址 - 应该返回true
|
|
||||||
assert!(is_private_ip("127.0.0.1"));
|
|
||||||
assert!(is_private_ip("localhost"));
|
|
||||||
|
|
||||||
// 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
|
|
||||||
assert!(!is_private_ip("172.0.0.1"));
|
|
||||||
assert!(!is_private_ip("172.15.255.255"));
|
|
||||||
assert!(!is_private_ip("172.32.0.1"));
|
|
||||||
assert!(!is_private_ip("172.64.0.1"));
|
|
||||||
assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
|
|
||||||
assert!(!is_private_ip("172.68.0.1"));
|
|
||||||
assert!(!is_private_ip("172.100.50.25"));
|
|
||||||
assert!(!is_private_ip("172.255.255.255"));
|
|
||||||
|
|
||||||
// 其他公网地址 - 应该返回false
|
|
||||||
assert!(!is_private_ip("8.8.8.8")); // Google DNS
|
|
||||||
assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
|
|
||||||
assert!(!is_private_ip("208.67.222.222")); // OpenDNS
|
|
||||||
assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
|
|
||||||
|
|
||||||
// 域名 - 应该返回false
|
|
||||||
assert!(!is_private_ip("api.example.com"));
|
|
||||||
assert!(!is_private_ip("www.google.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ipv6_private_validation() {
|
|
||||||
// IPv6私网地址
|
|
||||||
assert!(is_private_ip("::1")); // 环回地址
|
|
||||||
assert!(is_private_ip("fc00::1")); // 唯一本地地址
|
|
||||||
assert!(is_private_ip("fd00::1")); // 唯一本地地址
|
|
||||||
assert!(is_private_ip("fe80::1")); // 链路本地地址
|
|
||||||
assert!(is_private_ip("::")); // 未指定地址
|
|
||||||
|
|
||||||
// IPv6公网地址 - 应该返回false(修复的重点)
|
|
||||||
assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
|
|
||||||
assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
|
|
||||||
assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
|
|
||||||
assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
|
|
||||||
|
|
||||||
// 测试包含 ::1 子串但不是环回地址的公网地址
|
|
||||||
assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
|
|
||||||
assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_hostname_bypass_prevention() {
|
|
||||||
// 看起来像本地,但实际是域名
|
|
||||||
assert!(!is_private_ip("127.0.0.1.evil.com"));
|
|
||||||
assert!(!is_private_ip("localhost.evil.com"));
|
|
||||||
|
|
||||||
// 0.0.0.0 应该被视为本地/阻断
|
|
||||||
assert!(is_private_ip("0.0.0.0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_https_bypass_prevention() {
|
|
||||||
// 非本地域名的 HTTP 应该被拒绝
|
|
||||||
let result = validate_base_url("http://127.0.0.1.evil.com/api");
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"Should reject HTTP for non-localhost domains"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_edge_cases() {
|
|
||||||
// 边界情况测试
|
|
||||||
assert!(is_private_ip("172.16.0.0")); // RFC1918起始
|
|
||||||
assert!(is_private_ip("172.31.255.255")); // RFC1918结束
|
|
||||||
assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
|
|
||||||
assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
|
|
||||||
assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
|
|
||||||
assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
|
|
||||||
|
|
||||||
// 紧邻RFC1918的公网地址 - 应该返回false
|
|
||||||
assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
|
|
||||||
assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ip_addr_parsing() {
|
|
||||||
// 测试IP地址解析功能
|
|
||||||
let ipv4_private = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(is_private_ip_addr(ipv4_private));
|
|
||||||
|
|
||||||
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(!is_private_ip_addr(ipv4_public));
|
|
||||||
|
|
||||||
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(is_private_ip_addr(ipv6_private));
|
|
||||||
|
|
||||||
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
|
|
||||||
assert!(!is_private_ip_addr(ipv6_public));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_port_comparison() {
|
|
||||||
// 测试端口比较逻辑是否正确处理默认端口和显式端口
|
|
||||||
|
|
||||||
// 测试用例:(base_url, request_url, should_match)
|
|
||||||
let test_cases = vec![
|
|
||||||
// HTTPS默认端口测试
|
|
||||||
(
|
|
||||||
"https://api.example.com",
|
|
||||||
"https://api.example.com/v1/test",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"https://api.example.com",
|
|
||||||
"https://api.example.com:443/v1/test",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"https://api.example.com:443",
|
|
||||||
"https://api.example.com/v1/test",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"https://api.example.com:443",
|
|
||||||
"https://api.example.com:443/v1/test",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
// 端口不匹配测试
|
|
||||||
(
|
|
||||||
"https://api.example.com",
|
|
||||||
"https://api.example.com:8443/v1/test",
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"https://api.example.com:443",
|
|
||||||
"https://api.example.com:8443/v1/test",
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (base_url, request_url, should_match) in test_cases {
|
|
||||||
let result = validate_request_url(request_url, base_url);
|
|
||||||
|
|
||||||
if should_match {
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"应该匹配的URL被拒绝: base_url={}, request_url={}, error={}",
|
|
||||||
base_url,
|
|
||||||
request_url,
|
|
||||||
result.unwrap_err()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"应该不匹配的URL被允许: base_url={}, request_url={}",
|
|
||||||
base_url,
|
|
||||||
request_url
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"app": {
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"label": "main",
|
|
||||||
"titleBarStyle": "Visible"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{import_provider_from_deeplink, parse_deeplink_url, AppState, Database};
|
||||||
import_provider_from_deeplink, parse_deeplink_url, AppState, Database, ProxyService,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
@@ -18,11 +16,8 @@ fn deeplink_import_claude_provider_persists_to_db() {
|
|||||||
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
let db = Arc::new(Database::memory().expect("create memory db"));
|
let db = Arc::new(Database::memory().expect("create memory db"));
|
||||||
let proxy_service = ProxyService::new(db.clone());
|
|
||||||
let state = AppState {
|
let state = AppState { db: db.clone() };
|
||||||
db: db.clone(),
|
|
||||||
proxy_service,
|
|
||||||
};
|
|
||||||
|
|
||||||
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
.expect("import provider from deeplink");
|
.expect("import provider from deeplink");
|
||||||
@@ -58,11 +53,8 @@ fn deeplink_import_codex_provider_builds_auth_and_config() {
|
|||||||
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
let request = parse_deeplink_url(url).expect("parse deeplink url");
|
||||||
|
|
||||||
let db = Arc::new(Database::memory().expect("create memory db"));
|
let db = Arc::new(Database::memory().expect("create memory db"));
|
||||||
let proxy_service = ProxyService::new(db.clone());
|
|
||||||
let state = AppState {
|
let state = AppState { db: db.clone() };
|
||||||
db: db.clone(),
|
|
||||||
proxy_service,
|
|
||||||
};
|
|
||||||
|
|
||||||
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
let provider_id = import_provider_from_deeplink(&state, request.clone())
|
||||||
.expect("import provider from deeplink");
|
.expect("import provider from deeplink");
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{update_settings, AppSettings, AppState, Database, MultiAppConfig};
|
||||||
update_settings, AppSettings, AppState, Database, MultiAppConfig, ProxyService,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
/// 为测试设置隔离的 HOME 目录,避免污染真实用户数据。
|
||||||
pub fn ensure_test_home() -> &'static Path {
|
pub fn ensure_test_home() -> &'static Path {
|
||||||
@@ -50,17 +48,15 @@ pub fn test_mutex() -> &'static Mutex<()> {
|
|||||||
|
|
||||||
/// 创建测试用的 AppState,包含一个空的数据库
|
/// 创建测试用的 AppState,包含一个空的数据库
|
||||||
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
||||||
let db = Arc::new(Database::init()?);
|
let db = Database::init()?;
|
||||||
let proxy_service = ProxyService::new(db.clone());
|
Ok(AppState { db: Arc::new(db) })
|
||||||
Ok(AppState { db, proxy_service })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
|
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
|
||||||
pub fn create_test_state_with_config(
|
pub fn create_test_state_with_config(
|
||||||
config: &MultiAppConfig,
|
config: &MultiAppConfig,
|
||||||
) -> Result<AppState, Box<dyn std::error::Error>> {
|
) -> Result<AppState, Box<dyn std::error::Error>> {
|
||||||
let db = Arc::new(Database::init()?);
|
let db = Database::init()?;
|
||||||
db.migrate_from_json(config)?;
|
db.migrate_from_json(config)?;
|
||||||
let proxy_service = ProxyService::new(db.clone());
|
Ok(AppState { db: Arc::new(db) })
|
||||||
Ok(AppState { db, proxy_service })
|
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-36
@@ -23,7 +23,6 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
@@ -34,7 +33,6 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|||||||
import { SettingsPage } from "@/components/settings/SettingsPage";
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
||||||
import { UpdateBadge } from "@/components/UpdateBadge";
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
|
||||||
import { ProxyToggle } from "@/components/proxy/ProxyToggle";
|
|
||||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
@@ -64,17 +62,10 @@ function App() {
|
|||||||
const addActionButtonClass =
|
const addActionButtonClass =
|
||||||
"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 { data, isLoading, refetch } = useProvidersQuery(activeApp);
|
||||||
const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus();
|
|
||||||
|
|
||||||
// 获取供应商列表,当代理服务运行时自动刷新
|
|
||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
|
||||||
isProxyRunning,
|
|
||||||
});
|
|
||||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||||
const currentProviderId = data?.currentProviderId ?? "";
|
const currentProviderId = data?.currentProviderId ?? "";
|
||||||
// Skills 功能仅支持 Claude 和 Codex
|
const isClaudeApp = activeApp === "claude";
|
||||||
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
|
|
||||||
|
|
||||||
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
|
||||||
const {
|
const {
|
||||||
@@ -83,6 +74,7 @@ function App() {
|
|||||||
switchProvider,
|
switchProvider,
|
||||||
deleteProvider,
|
deleteProvider,
|
||||||
saveUsageScript,
|
saveUsageScript,
|
||||||
|
setProxyTarget,
|
||||||
} = useProviderActions(activeApp);
|
} = useProviderActions(activeApp);
|
||||||
|
|
||||||
// 监听来自托盘菜单的切换事件
|
// 监听来自托盘菜单的切换事件
|
||||||
@@ -142,7 +134,6 @@ function App() {
|
|||||||
if (migrated) {
|
if (migrated) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("migration.success", { defaultValue: "配置迁移成功" }),
|
t("migration.success", { defaultValue: "配置迁移成功" }),
|
||||||
{ closeButton: true },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -300,7 +291,6 @@ function App() {
|
|||||||
<SkillsPage
|
<SkillsPage
|
||||||
ref={skillsPageRef}
|
ref={skillsPageRef}
|
||||||
onClose={() => setCurrentView("providers")}
|
onClose={() => setCurrentView("providers")}
|
||||||
initialApp={activeApp}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "mcp":
|
case "mcp":
|
||||||
@@ -323,9 +313,8 @@ function App() {
|
|||||||
currentProviderId={currentProviderId}
|
currentProviderId={currentProviderId}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isProxyRunning={isProxyRunning}
|
|
||||||
isProxyTakeover={isProxyRunning && isTakeoverActive}
|
|
||||||
onSwitch={switchProvider}
|
onSwitch={switchProvider}
|
||||||
|
onSetProxyTarget={setProxyTarget}
|
||||||
onEdit={setEditingProvider}
|
onEdit={setEditingProvider}
|
||||||
onDelete={setConfirmDelete}
|
onDelete={setConfirmDelete}
|
||||||
onDuplicate={handleDuplicateProvider}
|
onDuplicate={handleDuplicateProvider}
|
||||||
@@ -379,7 +368,7 @@ 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="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag" } as any}
|
||||||
>
|
>
|
||||||
@@ -419,12 +408,7 @@ function App() {
|
|||||||
href="https://github.com/farion1231/cc-switch"
|
href="https://github.com/farion1231/cc-switch"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(
|
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
"text-xl font-semibold transition-colors",
|
|
||||||
isProxyRunning && isTakeoverActive
|
|
||||||
? "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",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
@@ -491,22 +475,24 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{currentView === "providers" && (
|
{currentView === "providers" && (
|
||||||
<>
|
<>
|
||||||
<ProxyToggle />
|
|
||||||
|
|
||||||
<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="glass p-1 rounded-xl flex items-center gap-1">
|
||||||
{hasSkillsSupport && (
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setCurrentView("skills")}
|
||||||
onClick={() => setCurrentView("skills")}
|
className={cn(
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||||
title={t("skills.manage")}
|
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||||
>
|
isClaudeApp
|
||||||
<Wrench className="h-4 w-4" />
|
? "opacity-100 w-8 scale-100 px-2"
|
||||||
</Button>
|
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||||
)}
|
)}
|
||||||
|
title={t("skills.manage")}
|
||||||
|
>
|
||||||
|
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||||
|
</Button>
|
||||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||||
{/* {isClaudeApp && (
|
{/* {isClaudeApp && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ 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-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("claude")}
|
onClick={() => handleSwitch("claude")}
|
||||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -41,7 +41,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
className={
|
className={
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>{appDisplayName.claude}</span>
|
<span>{appDisplayName.claude}</span>
|
||||||
@@ -50,10 +50,10 @@ 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={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "codex"
|
activeApp === "codex"
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -63,7 +63,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
className={
|
className={
|
||||||
activeApp === "codex"
|
activeApp === "codex"
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>{appDisplayName.codex}</span>
|
<span>{appDisplayName.codex}</span>
|
||||||
@@ -72,10 +72,10 @@ 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={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "gemini"
|
activeApp === "gemini"
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -85,7 +85,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
className={
|
className={
|
||||||
activeApp === "gemini"
|
activeApp === "gemini"
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground group-hover:text-foreground transition-colors"
|
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span>{appDisplayName.gemini}</span>
|
<span>{appDisplayName.gemini}</span>
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.mcpImportSuccessDescription", {
|
description: t("deeplink.mcpImportSuccessDescription", {
|
||||||
count: summary.importedCount,
|
count: summary.importedCount,
|
||||||
}),
|
}),
|
||||||
closeButton: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -143,7 +142,6 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.importSuccessDescription", {
|
description: t("deeplink.importSuccessDescription", {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
}),
|
}),
|
||||||
closeButton: true,
|
|
||||||
});
|
});
|
||||||
} else if (result.type === "prompt") {
|
} else if (result.type === "prompt") {
|
||||||
// Prompts don't use React Query, trigger a custom event for refresh
|
// Prompts don't use React Query, trigger a custom event for refresh
|
||||||
@@ -156,7 +154,6 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.promptImportSuccessDescription", {
|
description: t("deeplink.promptImportSuccessDescription", {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
}),
|
}),
|
||||||
closeButton: true,
|
|
||||||
});
|
});
|
||||||
} else if (result.type === "mcp") {
|
} else if (result.type === "mcp") {
|
||||||
await refreshMcp(result);
|
await refreshMcp(result);
|
||||||
@@ -174,7 +171,6 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.skillImportSuccessDescription", {
|
description: t("deeplink.skillImportSuccessDescription", {
|
||||||
repo: request.repo,
|
repo: request.repo,
|
||||||
}),
|
}),
|
||||||
closeButton: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (isMcpImportResult(result)) {
|
} else if (isMcpImportResult(result)) {
|
||||||
@@ -189,7 +185,6 @@ export function DeepLinkImportDialog() {
|
|||||||
description: t("deeplink.importSuccessDescription", {
|
description: t("deeplink.importSuccessDescription", {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
}),
|
}),
|
||||||
closeButton: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,86 +605,6 @@ export function DeepLinkImportDialog() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Usage Script Configuration (v3.9+) */}
|
|
||||||
{request.usageScript && (
|
|
||||||
<div className="space-y-3 pt-2 border-t border-border-default">
|
|
||||||
<div className="grid grid-cols-3 items-center gap-4">
|
|
||||||
<div className="font-medium text-sm text-muted-foreground">
|
|
||||||
{t("deeplink.usageScript", {
|
|
||||||
defaultValue: "用量查询",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 text-sm">
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
|
|
||||||
request.usageEnabled !== false
|
|
||||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
|
||||||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{request.usageEnabled !== false
|
|
||||||
? t("deeplink.usageScriptEnabled", {
|
|
||||||
defaultValue: "已启用",
|
|
||||||
})
|
|
||||||
: t("deeplink.usageScriptDisabled", {
|
|
||||||
defaultValue: "未启用",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage API Key (if different from provider) */}
|
|
||||||
{request.usageApiKey &&
|
|
||||||
request.usageApiKey !== request.apiKey && (
|
|
||||||
<div className="grid grid-cols-3 items-center gap-4">
|
|
||||||
<div className="font-medium text-sm text-muted-foreground">
|
|
||||||
{t("deeplink.usageApiKey", {
|
|
||||||
defaultValue: "用量 API Key",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 text-sm font-mono text-muted-foreground">
|
|
||||||
{request.usageApiKey.length > 4
|
|
||||||
? `${request.usageApiKey.substring(0, 4)}${"*".repeat(12)}`
|
|
||||||
: "****"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Usage Base URL (if different from provider) */}
|
|
||||||
{request.usageBaseUrl &&
|
|
||||||
request.usageBaseUrl !== request.endpoint && (
|
|
||||||
<div className="grid grid-cols-3 items-center gap-4">
|
|
||||||
<div className="font-medium text-sm text-muted-foreground">
|
|
||||||
{t("deeplink.usageBaseUrl", {
|
|
||||||
defaultValue: "用量查询地址",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 text-sm break-all">
|
|
||||||
{request.usageBaseUrl}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Auto Query Interval */}
|
|
||||||
{request.usageAutoInterval &&
|
|
||||||
request.usageAutoInterval > 0 && (
|
|
||||||
<div className="grid grid-cols-3 items-center gap-4">
|
|
||||||
<div className="font-medium text-sm text-muted-foreground">
|
|
||||||
{t("deeplink.usageAutoInterval", {
|
|
||||||
defaultValue: "自动查询",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 text-sm">
|
|
||||||
{t("deeplink.usageAutoIntervalValue", {
|
|
||||||
defaultValue: "每 {{minutes}} 分钟",
|
|
||||||
minutes: request.usageAutoInterval,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Warning */}
|
{/* Warning */}
|
||||||
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
{t("deeplink.warning")}
|
{t("deeplink.warning")}
|
||||||
|
|||||||
@@ -234,9 +234,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
|||||||
try {
|
try {
|
||||||
const formatted = formatJSON(currentValue);
|
const formatted = formatJSON(currentValue);
|
||||||
onChange(formatted);
|
onChange(formatted);
|
||||||
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }), {
|
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ interface ProviderIconProps {
|
|||||||
export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
||||||
icon,
|
icon,
|
||||||
name,
|
name,
|
||||||
color,
|
|
||||||
size = 32,
|
size = 32,
|
||||||
className,
|
className,
|
||||||
showFallback = true,
|
showFallback = true,
|
||||||
@@ -47,7 +46,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
|
|||||||
"inline-flex items-center justify-center flex-shrink-0",
|
"inline-flex items-center justify-center flex-shrink-0",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ ...sizeStyle, color }}
|
style={sizeStyle}
|
||||||
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
dangerouslySetInnerHTML={{ __html: iconSvg }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
closeButton: true,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -260,10 +259,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
printWidth: 80,
|
printWidth: 80,
|
||||||
});
|
});
|
||||||
setScript({ ...script, code: formatted.trim() });
|
setScript({ ...script, code: formatted.trim() });
|
||||||
toast.success(t("usageScript.formatSuccess"), {
|
toast.success(t("usageScript.formatSuccess"), { duration: 1000 });
|
||||||
duration: 1000,
|
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
|
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
|
||||||
@@ -404,25 +400,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
{/* 凭证配置 */}
|
{/* 凭证配置 */}
|
||||||
{shouldShowCredentialsConfig && (
|
{shouldShowCredentialsConfig && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start justify-between">
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
<h4 className="text-sm font-medium text-foreground">
|
{t("usageScript.credentialsConfig")}
|
||||||
{t("usageScript.credentialsConfig")}
|
</h4>
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("usageScript.credentialsHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-api-key">
|
<Label htmlFor="usage-api-key">API Key</Label>
|
||||||
API Key{" "}
|
|
||||||
<span className="text-xs text-muted-foreground font-normal">
|
|
||||||
({t("usageScript.optional")})
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="usage-api-key"
|
id="usage-api-key"
|
||||||
@@ -431,7 +417,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({ ...script, apiKey: e.target.value })
|
setScript({ ...script, apiKey: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={t("usageScript.apiKeyPlaceholder")}
|
placeholder="sk-xxxxx"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="border-white/10"
|
className="border-white/10"
|
||||||
/>
|
/>
|
||||||
@@ -458,10 +444,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="usage-base-url">
|
<Label htmlFor="usage-base-url">
|
||||||
{t("usageScript.baseUrl")}{" "}
|
{t("usageScript.baseUrl")}
|
||||||
<span className="text-xs text-muted-foreground font-normal">
|
|
||||||
({t("usageScript.optional")})
|
|
||||||
</span>
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="usage-base-url"
|
id="usage-base-url"
|
||||||
@@ -470,7 +453,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setScript({ ...script, baseUrl: e.target.value })
|
setScript({ ...script, baseUrl: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={t("usageScript.baseUrlPlaceholder")}
|
placeholder="https://api.example.com"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="border-white/10"
|
className="border-white/10"
|
||||||
/>
|
/>
|
||||||
|
|||||||
-1
@@ -79,7 +79,6 @@ export function EnvWarningBanner({
|
|||||||
path: backupInfo.backupPath,
|
path: backupInfo.backupPath,
|
||||||
}),
|
}),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
closeButton: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 清空选择并通知父组件
|
// 清空选择并通知父组件
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await upsertMutation.mutateAsync(entry);
|
await upsertMutation.mutateAsync(entry);
|
||||||
toast.success(t("common.success"), { closeButton: true });
|
toast.success(t("common.success"));
|
||||||
await onSave();
|
await onSave();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const detail = extractErrorMessage(error);
|
const detail = extractErrorMessage(error);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
try {
|
try {
|
||||||
await deleteServerMutation.mutateAsync(id);
|
await deleteServerMutation.mutateAsync(id);
|
||||||
setConfirmDialog(null);
|
setConfirmDialog(null);
|
||||||
toast.success(t("common.success"), { closeButton: true });
|
toast.success(t("common.success"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("common.error"), {
|
toast.error(t("common.error"), {
|
||||||
description: String(error),
|
description: String(error),
|
||||||
|
|||||||
@@ -33,23 +33,13 @@ export function EditProviderDialog({
|
|||||||
unknown
|
unknown
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
|
|
||||||
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
|
|
||||||
const [hasLoadedLive, setHasLoadedLive] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!open || !provider) {
|
if (!open || !provider) {
|
||||||
setLiveSettings(null);
|
setLiveSettings(null);
|
||||||
setHasLoadedLive(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关键修复:只在首次打开时加载一次
|
|
||||||
if (hasLoadedLive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentId = await providersApi.getCurrent(appId);
|
const currentId = await providersApi.getCurrent(appId);
|
||||||
if (currentId && provider.id === currentId) {
|
if (currentId && provider.id === currentId) {
|
||||||
@@ -59,20 +49,13 @@ export function EditProviderDialog({
|
|||||||
)) as Record<string, unknown>;
|
)) as Record<string, unknown>;
|
||||||
if (!cancelled && live && typeof live === "object") {
|
if (!cancelled && live && typeof live === "object") {
|
||||||
setLiveSettings(live);
|
setLiveSettings(live);
|
||||||
setHasLoadedLive(true);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
|
||||||
if (!cancelled) {
|
if (!cancelled) setLiveSettings(null);
|
||||||
setLiveSettings(null);
|
|
||||||
setHasLoadedLive(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!cancelled) {
|
if (!cancelled) setLiveSettings(null);
|
||||||
setLiveSettings(null);
|
|
||||||
setHasLoadedLive(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// no-op
|
// no-op
|
||||||
@@ -82,33 +65,14 @@ export function EditProviderDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
|
}, [open, provider, appId]);
|
||||||
|
|
||||||
const initialSettingsConfig = useMemo(() => {
|
const initialSettingsConfig = useMemo(() => {
|
||||||
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
}, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig,不依赖整个 provider
|
}, [liveSettings, provider]);
|
||||||
|
|
||||||
// 固定 initialData,防止 provider 对象更新时重置表单
|
|
||||||
const initialData = useMemo(() => {
|
|
||||||
if (!provider) return null;
|
|
||||||
return {
|
|
||||||
name: provider.name,
|
|
||||||
notes: provider.notes,
|
|
||||||
websiteUrl: provider.websiteUrl,
|
|
||||||
settingsConfig: initialSettingsConfig,
|
|
||||||
category: provider.category,
|
|
||||||
meta: provider.meta,
|
|
||||||
icon: provider.icon,
|
|
||||||
iconColor: provider.iconColor,
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
|
|
||||||
initialSettingsConfig,
|
|
||||||
// 注意:不依赖 provider 的其他字段,防止表单重置
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (values: ProviderFormValues) => {
|
async (values: ProviderFormValues) => {
|
||||||
@@ -140,7 +104,7 @@ export function EditProviderDialog({
|
|||||||
[onSubmit, onOpenChange, provider],
|
[onSubmit, onOpenChange, provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!provider || !initialData) {
|
if (!provider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +130,17 @@ export function EditProviderDialog({
|
|||||||
submitLabel={t("common.save")}
|
submitLabel={t("common.save")}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
initialData={initialData}
|
initialData={{
|
||||||
|
name: provider.name,
|
||||||
|
notes: provider.notes,
|
||||||
|
websiteUrl: provider.websiteUrl,
|
||||||
|
// 若读取到实时配置则优先使用
|
||||||
|
settingsConfig: initialSettingsConfig,
|
||||||
|
category: provider.category,
|
||||||
|
meta: provider.meta,
|
||||||
|
icon: provider.icon,
|
||||||
|
iconColor: provider.iconColor,
|
||||||
|
}}
|
||||||
showButtons={false}
|
showButtons={false}
|
||||||
/>
|
/>
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { HealthStatus } from "@/lib/api/model-test";
|
|
||||||
|
|
||||||
interface HealthStatusIndicatorProps {
|
|
||||||
status: HealthStatus;
|
|
||||||
responseTimeMs?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
operational: {
|
|
||||||
color: "bg-emerald-500",
|
|
||||||
label: "正常",
|
|
||||||
textColor: "text-emerald-600 dark:text-emerald-400",
|
|
||||||
},
|
|
||||||
degraded: {
|
|
||||||
color: "bg-yellow-500",
|
|
||||||
label: "降级",
|
|
||||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
color: "bg-red-500",
|
|
||||||
label: "失败",
|
|
||||||
textColor: "text-red-600 dark:text-red-400",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
|
|
||||||
status,
|
|
||||||
responseTimeMs,
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
const config = statusConfig[status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center gap-2", className)}>
|
|
||||||
<div className={cn("w-2 h-2 rounded-full", config.color)} />
|
|
||||||
<span className={cn("text-xs font-medium", config.textColor)}>
|
|
||||||
{config.label}
|
|
||||||
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -15,7 +15,6 @@ import { cn } from "@/lib/utils";
|
|||||||
interface ProviderActionsProps {
|
interface ProviderActionsProps {
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
isProxyTakeover?: boolean;
|
|
||||||
onSwitch: () => void;
|
onSwitch: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
@@ -27,7 +26,6 @@ interface ProviderActionsProps {
|
|||||||
export function ProviderActions({
|
export function ProviderActions({
|
||||||
isCurrent,
|
isCurrent,
|
||||||
isTesting,
|
isTesting,
|
||||||
isProxyTakeover = false,
|
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -49,10 +47,6 @@ export function ProviderActions({
|
|||||||
"w-[4.5rem] px-2.5",
|
"w-[4.5rem] px-2.5",
|
||||||
isCurrent &&
|
isCurrent &&
|
||||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
"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 ? (
|
{isCurrent ? (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo } from "react";
|
||||||
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
DraggableAttributes,
|
DraggableAttributes,
|
||||||
@@ -11,9 +11,8 @@ import { cn } from "@/lib/utils";
|
|||||||
import { ProviderActions } from "@/components/providers/ProviderActions";
|
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 { Switch } from "@/components/ui/switch";
|
||||||
import { useProviderHealth } from "@/lib/query/failover";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useUsageQuery } from "@/lib/query/queries";
|
|
||||||
|
|
||||||
interface DragHandleProps {
|
interface DragHandleProps {
|
||||||
attributes: DraggableAttributes;
|
attributes: DraggableAttributes;
|
||||||
@@ -33,8 +32,8 @@ interface ProviderCardProps {
|
|||||||
onDuplicate: (provider: Provider) => void;
|
onDuplicate: (provider: Provider) => void;
|
||||||
onTest?: (provider: Provider) => void;
|
onTest?: (provider: Provider) => void;
|
||||||
isTesting?: boolean;
|
isTesting?: boolean;
|
||||||
|
onSetProxyTarget: (provider: Provider) => void;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
|
||||||
dragHandleProps?: DragHandleProps;
|
dragHandleProps?: DragHandleProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,15 +84,12 @@ export function ProviderCard({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onTest,
|
onTest,
|
||||||
isTesting,
|
isTesting,
|
||||||
|
onSetProxyTarget,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover = false,
|
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 获取供应商健康状态
|
|
||||||
const { data: health } = useProviderHealth(provider.id, appId);
|
|
||||||
|
|
||||||
const fallbackUrlText = t("provider.notConfigured", {
|
const fallbackUrlText = t("provider.notConfigured", {
|
||||||
defaultValue: "未配置接口地址",
|
defaultValue: "未配置接口地址",
|
||||||
});
|
});
|
||||||
@@ -118,29 +114,6 @@ export function ProviderCard({
|
|||||||
|
|
||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
|
|
||||||
// 获取用量数据以判断是否有多套餐
|
|
||||||
const autoQueryInterval = isCurrent
|
|
||||||
? provider.meta?.usage_script?.autoQueryInterval || 0
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const { data: usage } = useUsageQuery(provider.id, appId, {
|
|
||||||
enabled: usageEnabled,
|
|
||||||
autoQueryInterval,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasMultiplePlans =
|
|
||||||
usage?.success && usage.data && usage.data.length > 1;
|
|
||||||
|
|
||||||
// 多套餐默认展开
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
// 当检测到多套餐时自动展开
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasMultiplePlans) {
|
|
||||||
setIsExpanded(true);
|
|
||||||
}
|
|
||||||
}, [hasMultiplePlans]);
|
|
||||||
|
|
||||||
const handleOpenWebsite = () => {
|
const handleOpenWebsite = () => {
|
||||||
if (!isClickableUrl) {
|
if (!isClickableUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -151,32 +124,14 @@ export function ProviderCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
|
||||||
"bg-card text-card-foreground group",
|
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
|
||||||
// 代理接管模式下 hover 使用绿色边框,否则使用蓝色
|
isCurrent ? "glass-card-active" : "hover:scale-[1.01]",
|
||||||
isProxyTakeover
|
|
||||||
? "hover:border-emerald-500/50"
|
|
||||||
: "hover:border-border-active",
|
|
||||||
// 代理接管模式下当前供应商使用绿色边框
|
|
||||||
isProxyTakeover && isCurrent
|
|
||||||
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
|
|
||||||
: isCurrent
|
|
||||||
? "border-primary/50 shadow-sm"
|
|
||||||
: "hover:shadow-sm",
|
|
||||||
dragHandleProps?.isDragging &&
|
dragHandleProps?.isDragging &&
|
||||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
|
||||||
// 代理接管模式下使用绿色渐变,否则使用蓝色主色调
|
|
||||||
isProxyTakeover && isCurrent
|
|
||||||
? "from-emerald-500/10"
|
|
||||||
: "from-primary/10",
|
|
||||||
isCurrent ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -194,7 +149,7 @@ export function ProviderCard({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 供应商图标 */}
|
{/* 供应商图标 */}
|
||||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
|
<div className="h-8 w-8 rounded-lg bg-white/5 flex items-center justify-center border border-gray-200 dark:border-white/10 group-hover:scale-105 transition-transform duration-300">
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
icon={provider.icon}
|
icon={provider.icon}
|
||||||
name={provider.name}
|
name={provider.name}
|
||||||
@@ -204,18 +159,10 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2 min-h-7">
|
<div className="flex flex-wrap items-center gap-2 min-h-[20px]">
|
||||||
<h3 className="text-base font-semibold leading-none">
|
<h3 className="text-base font-semibold leading-none">
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 健康状态徽章和优先级 */}
|
|
||||||
{isProxyRunning && health && (
|
|
||||||
<ProviderHealthBadge
|
|
||||||
consecutiveFailures={health.consecutive_failures}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{provider.category === "third_party" &&
|
{provider.category === "third_party" &&
|
||||||
provider.meta?.isPartner && (
|
provider.meta?.isPartner && (
|
||||||
<span
|
<span
|
||||||
@@ -227,6 +174,44 @@ export function ProviderCard({
|
|||||||
⭐
|
⭐
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 代理目标开关 - 仅在代理服务运行时显示 */}
|
||||||
|
{isProxyRunning && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 ml-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
id={`proxy-target-switch-${provider.id}`}
|
||||||
|
checked={provider.isProxyTarget || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked && !provider.isProxyTarget) {
|
||||||
|
onSetProxyTarget(provider);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={provider.isProxyTarget}
|
||||||
|
className="scale-75 data-[state=checked]:bg-purple-500"
|
||||||
|
/>
|
||||||
|
{provider.isProxyTarget && (
|
||||||
|
<Label
|
||||||
|
htmlFor={`proxy-target-switch-${provider.id}`}
|
||||||
|
className="text-xs font-medium text-purple-500 dark:text-purple-400 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
{!provider.isProxyTarget && (
|
||||||
|
<Label
|
||||||
|
htmlFor={`proxy-target-switch-${provider.id}`}
|
||||||
|
className="text-xs text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
{t("provider.setAsProxyTarget", {
|
||||||
|
defaultValue: "设为代理",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{displayUrl && (
|
{displayUrl && (
|
||||||
@@ -248,60 +233,22 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center ml-auto min-w-0">
|
<div className="relative flex items-center ml-auto">
|
||||||
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
|
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[12.25rem] group-focus-within:-translate-x-[12.25rem] sm:group-hover:-translate-x-[14.25rem] sm:group-focus-within:-translate-x-[14.25rem]">
|
||||||
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
|
<UsageFooter
|
||||||
<div className="flex items-center gap-1">
|
provider={provider}
|
||||||
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
|
providerId={provider.id}
|
||||||
{hasMultiplePlans ? (
|
appId={appId}
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
usageEnabled={usageEnabled}
|
||||||
<span className="font-medium">
|
isCurrent={isCurrent}
|
||||||
{t("usage.multiplePlans", {
|
inline={true}
|
||||||
count: usage?.data?.length || 0,
|
/>
|
||||||
defaultValue: `${usage?.data?.length || 0} 个套餐`,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<UsageFooter
|
|
||||||
provider={provider}
|
|
||||||
providerId={provider.id}
|
|
||||||
appId={appId}
|
|
||||||
usageEnabled={usageEnabled}
|
|
||||||
isCurrent={isCurrent}
|
|
||||||
inline={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* 展开/折叠按钮 - 仅在有多套餐时显示 */}
|
|
||||||
{hasMultiplePlans && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsExpanded(!isExpanded);
|
|
||||||
}}
|
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400 flex-shrink-0"
|
|
||||||
title={
|
|
||||||
isExpanded
|
|
||||||
? t("usage.collapse", { defaultValue: "收起" })
|
|
||||||
: t("usage.expand", { defaultValue: "展开" })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp size={14} />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
|
|
||||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||||
<ProviderActions
|
<ProviderActions
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
isProxyTakeover={isProxyTakeover}
|
|
||||||
onSwitch={() => onSwitch(provider)}
|
onSwitch={() => onSwitch(provider)}
|
||||||
onEdit={() => onEdit(provider)}
|
onEdit={() => onEdit(provider)}
|
||||||
onDuplicate={() => onDuplicate(provider)}
|
onDuplicate={() => onDuplicate(provider)}
|
||||||
@@ -312,20 +259,6 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 展开的完整套餐列表 */}
|
|
||||||
{isExpanded && hasMultiplePlans && (
|
|
||||||
<div className="mt-4 pt-4 border-t border-border-default">
|
|
||||||
<UsageFooter
|
|
||||||
provider={provider}
|
|
||||||
providerId={provider.id}
|
|
||||||
appId={appId}
|
|
||||||
usageEnabled={usageEnabled}
|
|
||||||
isCurrent={isCurrent}
|
|
||||||
inline={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-10 text-center">
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center">
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
<Users className="h-7 w-7 text-muted-foreground" />
|
<Users className="h-7 w-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ProviderHealthStatus } from "@/types/proxy";
|
|
||||||
|
|
||||||
interface ProviderHealthBadgeProps {
|
|
||||||
consecutiveFailures: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 供应商健康状态徽章
|
|
||||||
* 根据连续失败次数显示不同颜色的状态指示器
|
|
||||||
*/
|
|
||||||
export function ProviderHealthBadge({
|
|
||||||
consecutiveFailures,
|
|
||||||
className,
|
|
||||||
}: ProviderHealthBadgeProps) {
|
|
||||||
// 根据失败次数计算状态
|
|
||||||
const getStatus = () => {
|
|
||||||
if (consecutiveFailures === 0) {
|
|
||||||
return {
|
|
||||||
label: "正常",
|
|
||||||
status: ProviderHealthStatus.Healthy,
|
|
||||||
color: "bg-green-500",
|
|
||||||
// 使用更深/柔和的背景色,去除可能的白色内容感
|
|
||||||
bgColor: "bg-green-500/10",
|
|
||||||
textColor: "text-green-600 dark:text-green-400",
|
|
||||||
};
|
|
||||||
} else if (consecutiveFailures < 5) {
|
|
||||||
return {
|
|
||||||
label: "降级",
|
|
||||||
status: ProviderHealthStatus.Degraded,
|
|
||||||
color: "bg-yellow-500",
|
|
||||||
bgColor: "bg-yellow-500/10",
|
|
||||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: "熔断",
|
|
||||||
status: ProviderHealthStatus.Failed,
|
|
||||||
color: "bg-red-500",
|
|
||||||
bgColor: "bg-red-500/10",
|
|
||||||
textColor: "text-red-600 dark:text-red-400",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusConfig = getStatus();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium",
|
|
||||||
statusConfig.bgColor,
|
|
||||||
statusConfig.textColor,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
title={`连续失败 ${consecutiveFailures} 次`}
|
|
||||||
>
|
|
||||||
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
|
|
||||||
<span>{statusConfig.label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,8 @@ import type { CSSProperties } from "react";
|
|||||||
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 { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
|
import { useModelTest } from "@/hooks/useModelTest";
|
||||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||||
|
|
||||||
@@ -25,8 +26,7 @@ interface ProviderListProps {
|
|||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onCreate?: () => void;
|
onCreate?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isProxyRunning?: boolean; // 代理服务运行状态
|
onSetProxyTarget: (provider: Provider) => void;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderList({
|
export function ProviderList({
|
||||||
@@ -41,19 +41,21 @@ export function ProviderList({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isProxyRunning = false, // 默认值为 false
|
onSetProxyTarget,
|
||||||
isProxyTakeover = false, // 默认值为 false
|
|
||||||
}: ProviderListProps) {
|
}: ProviderListProps) {
|
||||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||||
providers,
|
providers,
|
||||||
appId,
|
appId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 流式健康检查
|
// 获取代理服务运行状态
|
||||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
const { isRunning: isProxyRunning } = useProxyStatus();
|
||||||
|
|
||||||
|
// 模型测试
|
||||||
|
const { testProvider, isTesting } = useModelTest(appId);
|
||||||
|
|
||||||
const handleTest = (provider: Provider) => {
|
const handleTest = (provider: Provider) => {
|
||||||
checkProvider(provider.id, provider.name);
|
testProvider(provider.id, provider.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -100,9 +102,9 @@ export function ProviderList({
|
|||||||
onConfigureUsage={onConfigureUsage}
|
onConfigureUsage={onConfigureUsage}
|
||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
onTest={handleTest}
|
onTest={handleTest}
|
||||||
isTesting={isChecking(provider.id)}
|
isTesting={isTesting(provider.id)}
|
||||||
|
onSetProxyTarget={onSetProxyTarget}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
isProxyTakeover={isProxyTakeover}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,8 +125,8 @@ interface SortableProviderCardProps {
|
|||||||
onOpenWebsite: (url: string) => void;
|
onOpenWebsite: (url: string) => void;
|
||||||
onTest: (provider: Provider) => void;
|
onTest: (provider: Provider) => void;
|
||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
|
onSetProxyTarget: (provider: Provider) => void;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableProviderCard({
|
function SortableProviderCard({
|
||||||
@@ -139,8 +141,8 @@ function SortableProviderCard({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onTest,
|
onTest,
|
||||||
isTesting,
|
isTesting,
|
||||||
|
onSetProxyTarget,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover,
|
|
||||||
}: SortableProviderCardProps) {
|
}: SortableProviderCardProps) {
|
||||||
const {
|
const {
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
@@ -172,8 +174,8 @@ function SortableProviderCard({
|
|||||||
onOpenWebsite={onOpenWebsite}
|
onOpenWebsite={onOpenWebsite}
|
||||||
onTest={onTest}
|
onTest={onTest}
|
||||||
isTesting={isTesting}
|
isTesting={isTesting}
|
||||||
|
onSetProxyTarget={onSetProxyTarget}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
isProxyTakeover={isProxyTakeover}
|
|
||||||
dragHandleProps={{
|
dragHandleProps={{
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<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-gray-300 dark:border-gray-600 hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-gray-800/50 flex items-center justify-center"
|
||||||
title={currentIcon ? "点击更换图标" : "点击选择图标"}
|
title={currentIcon ? "点击更换图标" : "点击选择图标"}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
onClick={() => handleSelect(entry.url)}
|
onClick={() => handleSelect(entry.url)}
|
||||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition text-foreground ${
|
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary/70 bg-primary/5 shadow-sm"
|
? "border-primary/70 bg-primary/5 shadow-sm"
|
||||||
: "border-border-default bg-background hover:bg-muted"
|
: "border-border-default bg-background hover:bg-muted"
|
||||||
@@ -577,7 +577,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
|||||||
|
|
||||||
{/* 内容 */}
|
{/* 内容 */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm text-foreground">
|
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
|
||||||
{entry.url}
|
{entry.url}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Save, Loader2, Info } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
useCircuitBreakerConfig,
|
|
||||||
useUpdateCircuitBreakerConfig,
|
|
||||||
} from "@/lib/query/failover";
|
|
||||||
|
|
||||||
export interface AutoFailoverConfigPanelProps {
|
|
||||||
enabled: boolean;
|
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AutoFailoverConfigPanel({
|
|
||||||
enabled,
|
|
||||||
onEnabledChange: _onEnabledChange,
|
|
||||||
}: AutoFailoverConfigPanelProps) {
|
|
||||||
// Note: onEnabledChange is currently unused but kept in the interface
|
|
||||||
// for potential future use by parent components
|
|
||||||
void _onEnabledChange;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: config, isLoading, error } = useCircuitBreakerConfig();
|
|
||||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
failureThreshold: 5,
|
|
||||||
successThreshold: 2,
|
|
||||||
timeoutSeconds: 60,
|
|
||||||
errorRateThreshold: 0.5,
|
|
||||||
minRequests: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (config) {
|
|
||||||
setFormData({
|
|
||||||
...config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await updateConfig.mutateAsync({
|
|
||||||
failureThreshold: formData.failureThreshold,
|
|
||||||
successThreshold: formData.successThreshold,
|
|
||||||
timeoutSeconds: formData.timeoutSeconds,
|
|
||||||
errorRateThreshold: formData.errorRateThreshold,
|
|
||||||
minRequests: formData.minRequests,
|
|
||||||
});
|
|
||||||
toast.success(
|
|
||||||
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
|
||||||
{ closeButton: true },
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.autoFailover.configSaveFailed", "保存失败") + ": " + String(e),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (config) {
|
|
||||||
setFormData({
|
|
||||||
...config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-4">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{String(error)}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert className="border-blue-500/40 bg-blue-500/10">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.info",
|
|
||||||
"当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。",
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* 重试与超时配置 */}
|
|
||||||
<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.retrySettings", "重试与超时设置")}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="failureThreshold">
|
|
||||||
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="failureThreshold"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
value={formData.failureThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
failureThreshold: parseInt(e.target.value) || 5,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!enabled}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.failureThresholdHint",
|
|
||||||
"连续失败多少次后打开熔断器(建议: 3-10)",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timeoutSeconds">
|
|
||||||
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="timeoutSeconds"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="300"
|
|
||||||
value={formData.timeoutSeconds}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
timeoutSeconds: parseInt(e.target.value) || 60,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!enabled}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.timeoutHint",
|
|
||||||
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 熔断器高级配置 */}
|
|
||||||
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
|
||||||
<h4 className="text-sm font-semibold">
|
|
||||||
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="successThreshold">
|
|
||||||
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="successThreshold"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
value={formData.successThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
successThreshold: parseInt(e.target.value) || 2,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!enabled}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.successThresholdHint",
|
|
||||||
"半开状态下成功多少次后关闭熔断器",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="errorRateThreshold">
|
|
||||||
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="errorRateThreshold"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="5"
|
|
||||||
value={Math.round(formData.errorRateThreshold * 100)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!enabled}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.errorRateHint",
|
|
||||||
"错误率超过此值时打开熔断器",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="minRequests">
|
|
||||||
{t("proxy.autoFailover.minRequests", "最小请求数")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="minRequests"
|
|
||||||
type="number"
|
|
||||||
min="5"
|
|
||||||
max="100"
|
|
||||||
value={formData.minRequests}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
minRequests: parseInt(e.target.value) || 10,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!enabled}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.minRequestsHint",
|
|
||||||
"计算错误率前的最小请求数",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={updateConfig.isPending || !enabled}
|
|
||||||
>
|
|
||||||
{t("common.reset", "重置")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={updateConfig.isPending || !enabled}
|
|
||||||
>
|
|
||||||
{updateConfig.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t("common.saving", "保存中...")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
{t("common.save", "保存")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import {
|
|
||||||
useCircuitBreakerConfig,
|
|
||||||
useUpdateCircuitBreakerConfig,
|
|
||||||
} from "@/lib/query/failover";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 熔断器配置面板
|
|
||||||
* 允许用户调整熔断器参数
|
|
||||||
*/
|
|
||||||
export function CircuitBreakerConfigPanel() {
|
|
||||||
const { data: config, isLoading } = useCircuitBreakerConfig();
|
|
||||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
failureThreshold: 5,
|
|
||||||
successThreshold: 2,
|
|
||||||
timeoutSeconds: 60,
|
|
||||||
errorRateThreshold: 0.5,
|
|
||||||
minRequests: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 当配置加载完成时更新表单数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (config) {
|
|
||||||
setFormData(config);
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await updateConfig.mutateAsync(formData);
|
|
||||||
toast.success("熔断器配置已保存", { closeButton: true });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("保存失败: " + String(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
if (config) {
|
|
||||||
setFormData(config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="text-sm text-muted-foreground">加载中...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">熔断器配置</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
调整熔断器参数以控制故障检测和恢复行为
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px bg-border my-4" />
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{/* 失败阈值 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="failureThreshold">失败阈值</Label>
|
|
||||||
<Input
|
|
||||||
id="failureThreshold"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="20"
|
|
||||||
value={formData.failureThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
failureThreshold: parseInt(e.target.value) || 5,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
连续失败多少次后打开熔断器
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 超时时间 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timeoutSeconds">超时时间(秒)</Label>
|
|
||||||
<Input
|
|
||||||
id="timeoutSeconds"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="300"
|
|
||||||
value={formData.timeoutSeconds}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
timeoutSeconds: parseInt(e.target.value) || 60,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
熔断器打开后多久尝试恢复(半开状态)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 成功阈值 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="successThreshold">成功阈值</Label>
|
|
||||||
<Input
|
|
||||||
id="successThreshold"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
value={formData.successThreshold}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
successThreshold: parseInt(e.target.value) || 2,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
半开状态下成功多少次后关闭熔断器
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误率阈值 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="errorRateThreshold">错误率阈值 (%)</Label>
|
|
||||||
<Input
|
|
||||||
id="errorRateThreshold"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="5"
|
|
||||||
value={Math.round(formData.errorRateThreshold * 100)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
错误率超过此值时打开熔断器
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 最小请求数 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="minRequests">最小请求数</Label>
|
|
||||||
<Input
|
|
||||||
id="minRequests"
|
|
||||||
type="number"
|
|
||||||
min="5"
|
|
||||||
max="100"
|
|
||||||
value={formData.minRequests}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
minRequests: parseInt(e.target.value) || 10,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
计算错误率前的最小请求数
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button onClick={handleSave} disabled={updateConfig.isPending}>
|
|
||||||
{updateConfig.isPending ? "保存中..." : "保存配置"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={updateConfig.isPending}
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 说明信息 */}
|
|
||||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
|
|
||||||
<h4 className="font-medium">配置说明</h4>
|
|
||||||
<ul className="space-y-1 text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
• <strong>失败阈值</strong>:连续失败达到此次数时,熔断器打开
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>超时时间</strong>:熔断器打开后,等待此时间后尝试半开
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>成功阈值</strong>:半开状态下,成功达到此次数时关闭熔断器
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>错误率阈值</strong>:错误率超过此值时,熔断器打开
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• <strong>最小请求数</strong>:只有请求数达到此值后才计算错误率
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
/**
|
|
||||||
* 故障转移队列管理组件
|
|
||||||
*
|
|
||||||
* 允许用户管理代理模式下的故障转移队列,支持:
|
|
||||||
* - 拖拽排序
|
|
||||||
* - 添加/移除供应商
|
|
||||||
* - 启用/禁用队列项
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
|
||||||
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 {
|
|
||||||
GripVertical,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
Info,
|
|
||||||
AlertTriangle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { FailoverQueueItem } from "@/types/proxy";
|
|
||||||
import type { AppId } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
useFailoverQueue,
|
|
||||||
useAvailableProvidersForFailover,
|
|
||||||
useAddToFailoverQueue,
|
|
||||||
useRemoveFromFailoverQueue,
|
|
||||||
useReorderFailoverQueue,
|
|
||||||
useSetFailoverItemEnabled,
|
|
||||||
} from "@/lib/query/failover";
|
|
||||||
|
|
||||||
interface FailoverQueueManagerProps {
|
|
||||||
appType: AppId;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FailoverQueueManager({
|
|
||||||
appType,
|
|
||||||
disabled = false,
|
|
||||||
}: FailoverQueueManagerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
|
||||||
|
|
||||||
// 查询数据
|
|
||||||
const {
|
|
||||||
data: queue,
|
|
||||||
isLoading: isQueueLoading,
|
|
||||||
error: queueError,
|
|
||||||
} = useFailoverQueue(appType);
|
|
||||||
const { data: availableProviders, isLoading: isProvidersLoading } =
|
|
||||||
useAvailableProvidersForFailover(appType);
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const addToQueue = useAddToFailoverQueue();
|
|
||||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
|
||||||
const reorderQueue = useReorderFailoverQueue();
|
|
||||||
const setItemEnabled = useSetFailoverItemEnabled();
|
|
||||||
|
|
||||||
// 拖拽配置
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: { distance: 8 },
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 排序后的队列
|
|
||||||
const sortedQueue = useMemo(() => {
|
|
||||||
if (!queue) return [];
|
|
||||||
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
|
|
||||||
}, [queue]);
|
|
||||||
|
|
||||||
// 处理拖拽结束
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id || !sortedQueue) return;
|
|
||||||
|
|
||||||
const oldIndex = sortedQueue.findIndex(
|
|
||||||
(item) => item.providerId === active.id,
|
|
||||||
);
|
|
||||||
const newIndex = sortedQueue.findIndex(
|
|
||||||
(item) => item.providerId === over.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
|
||||||
|
|
||||||
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
|
|
||||||
const providerIds = reordered.map((item) => item.providerId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await reorderQueue.mutateAsync({ appType, providerIds });
|
|
||||||
toast.success(
|
|
||||||
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
|
|
||||||
{ closeButton: true },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
|
|
||||||
": " +
|
|
||||||
String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sortedQueue, appType, reorderQueue, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 添加供应商到队列
|
|
||||||
const handleAddProvider = async () => {
|
|
||||||
if (!selectedProviderId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addToQueue.mutateAsync({
|
|
||||||
appType,
|
|
||||||
providerId: selectedProviderId,
|
|
||||||
});
|
|
||||||
setSelectedProviderId("");
|
|
||||||
toast.success(
|
|
||||||
t("proxy.failoverQueue.addSuccess", "已添加到故障转移队列"),
|
|
||||||
{ closeButton: true },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.failoverQueue.addFailed", "添加失败") + ": " + String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从队列移除供应商
|
|
||||||
const handleRemoveProvider = async (providerId: string) => {
|
|
||||||
try {
|
|
||||||
await removeFromQueue.mutateAsync({ appType, providerId });
|
|
||||||
toast.success(
|
|
||||||
t("proxy.failoverQueue.removeSuccess", "已从故障转移队列移除"),
|
|
||||||
{ closeButton: true },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.failoverQueue.removeFailed", "移除失败") +
|
|
||||||
": " +
|
|
||||||
String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换启用状态
|
|
||||||
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) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queueError) {
|
|
||||||
return (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{String(queueError)}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 说明信息 */}
|
|
||||||
<Alert className="border-blue-500/40 bg-blue-500/10">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
{t(
|
|
||||||
"proxy.failoverQueue.info",
|
|
||||||
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* 添加供应商 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
value={selectedProviderId}
|
|
||||||
onValueChange={setSelectedProviderId}
|
|
||||||
disabled={disabled || isProvidersLoading}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="flex-1">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t(
|
|
||||||
"proxy.failoverQueue.selectProvider",
|
|
||||||
"选择供应商添加到队列",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availableProviders?.map((provider) => (
|
|
||||||
<SelectItem key={provider.id} value={provider.id}>
|
|
||||||
{provider.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{(!availableProviders || availableProviders.length === 0) && (
|
|
||||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.failoverQueue.noAvailableProviders",
|
|
||||||
"没有可添加的供应商",
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
onClick={handleAddProvider}
|
|
||||||
disabled={disabled || !selectedProviderId || addToQueue.isPending}
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{addToQueue.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 队列列表 */}
|
|
||||||
{sortedQueue.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.failoverQueue.empty",
|
|
||||||
"故障转移队列为空。添加供应商以启用自动故障转移。",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={sortedQueue.map((item) => item.providerId)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sortedQueue.map((item, index) => (
|
|
||||||
<SortableQueueItem
|
|
||||||
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 && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"proxy.failoverQueue.dragHint",
|
|
||||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SortableQueueItemProps {
|
|
||||||
item: FailoverQueueItem;
|
|
||||||
index: number;
|
|
||||||
disabled: boolean;
|
|
||||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
|
||||||
onRemove: (providerId: string) => void;
|
|
||||||
isRemoving: boolean;
|
|
||||||
isToggling: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableQueueItem({
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
disabled,
|
|
||||||
onToggleEnabled,
|
|
||||||
onRemove,
|
|
||||||
isRemoving,
|
|
||||||
isToggling,
|
|
||||||
}: SortableQueueItemProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
setNodeRef,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: item.providerId, disabled });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
|
||||||
"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">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 供应商名称 */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium truncate block",
|
|
||||||
!item.enabled && "text-muted-foreground line-through",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.providerName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 启用开关 */}
|
|
||||||
<Switch
|
|
||||||
checked={item.enabled}
|
|
||||||
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
|
|
||||||
disabled={disabled || isToggling}
|
|
||||||
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 删除按钮 */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => onRemove(item.providerId)}
|
|
||||||
disabled={disabled || isRemoving}
|
|
||||||
aria-label={t("common.delete", "删除")}
|
|
||||||
>
|
|
||||||
{isRemoving ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { Switch } from "@/components/ui/switch";
|
||||||
Activity,
|
import { Badge } from "@/components/ui/badge";
|
||||||
Clock,
|
|
||||||
TrendingUp,
|
|
||||||
Server,
|
|
||||||
ListOrdered,
|
|
||||||
Settings,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
|
import { Settings, Activity, Clock, TrendingUp, Server } from "lucide-react";
|
||||||
import { ProxySettingsDialog } from "./ProxySettingsDialog";
|
import { ProxySettingsDialog } from "./ProxySettingsDialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useFailoverQueue } from "@/lib/query/failover";
|
|
||||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
|
||||||
import { useProviderHealth } from "@/lib/query/failover";
|
|
||||||
import type { ProxyStatus } from "@/types/proxy";
|
|
||||||
|
|
||||||
export function ProxyPanel() {
|
export function ProxyPanel() {
|
||||||
const { status, isRunning } = useProxyStatus();
|
const { status, isRunning, start, stop, isPending } = useProxyStatus();
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
|
const handleToggle = async () => {
|
||||||
// 当前供应商始终优先,队列仅用于失败后的备用顺序
|
try {
|
||||||
const { data: claudeQueue = [] } = useFailoverQueue("claude");
|
if (isRunning) {
|
||||||
const { data: codexQueue = [] } = useFailoverQueue("codex");
|
await stop();
|
||||||
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
|
} else {
|
||||||
|
await start();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Toggle proxy failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatUptime = (seconds: number): string => {
|
const formatUptime = (seconds: number): string => {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
@@ -42,24 +39,59 @@ export function ProxyPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="space-y-6">
|
<section className="space-y-6 rounded-xl border border-white/10 glass-card p-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
|
本地代理服务
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isRunning
|
||||||
|
? `运行中 · ${status?.address}:${status?.port}`
|
||||||
|
: "已停止"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge
|
||||||
|
variant={isRunning ? "default" : "secondary"}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Activity
|
||||||
|
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||||
|
/>
|
||||||
|
{isRunning ? "运行中" : "已停止"}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="打开代理设置"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={isRunning}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label={isRunning ? "停止代理服务" : "启动代理服务"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isRunning && status ? (
|
{isRunning && status ? (
|
||||||
<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-white/10 bg-muted/40 p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
<p className="text-xs text-muted-foreground">服务地址</p>
|
服务地址
|
||||||
<Button
|
</p>
|
||||||
size="sm"
|
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setShowSettings(true)}
|
|
||||||
className="h-7 gap-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
配置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<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}
|
||||||
</code>
|
</code>
|
||||||
@@ -70,7 +102,7 @@ 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("地址已复制");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
复制
|
复制
|
||||||
@@ -78,14 +110,16 @@ export function ProxyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-border space-y-2">
|
<div className="pt-3 border-t border-white/10 space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">使用中</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
当前代理
|
||||||
|
</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) => (
|
||||||
<div
|
<div
|
||||||
key={target.app_type}
|
key={target.app_type}
|
||||||
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-2 py-1.5 text-xs"
|
className="flex items-center justify-between rounded-md border border-white/10 bg-background/60 px-2 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{target.app_type}
|
{target.app_type}
|
||||||
@@ -112,68 +146,6 @@ export function ProxyPanel() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 供应商队列 - 按应用类型分组展示 */}
|
|
||||||
{(claudeQueue.length > 0 ||
|
|
||||||
codexQueue.length > 0 ||
|
|
||||||
geminiQueue.length > 0) && (
|
|
||||||
<div className="pt-3 border-t border-border space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
故障转移队列
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Claude 队列 */}
|
|
||||||
{claudeQueue.length > 0 && (
|
|
||||||
<ProviderQueueGroup
|
|
||||||
appType="claude"
|
|
||||||
appLabel="Claude"
|
|
||||||
targets={claudeQueue
|
|
||||||
.filter((item) => item.enabled)
|
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
|
||||||
.map((item) => ({
|
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Codex 队列 */}
|
|
||||||
{codexQueue.length > 0 && (
|
|
||||||
<ProviderQueueGroup
|
|
||||||
appType="codex"
|
|
||||||
appLabel="Codex"
|
|
||||||
targets={codexQueue
|
|
||||||
.filter((item) => item.enabled)
|
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
|
||||||
.map((item) => ({
|
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gemini 队列 */}
|
|
||||||
{geminiQueue.length > 0 && (
|
|
||||||
<ProviderQueueGroup
|
|
||||||
appType="gemini"
|
|
||||||
appLabel="Gemini"
|
|
||||||
targets={geminiQueue
|
|
||||||
.filter((item) => item.enabled)
|
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
|
||||||
.map((item) => ({
|
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
@@ -208,18 +180,9 @@ export function ProxyPanel() {
|
|||||||
<p className="text-base font-medium text-foreground mb-1">
|
<p className="text-base font-medium text-foreground mb-1">
|
||||||
代理服务已停止
|
代理服务已停止
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
使用右上角开关即可启动服务
|
使用右上角开关即可启动服务
|
||||||
</p>
|
</p>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowSettings(true)}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
配置代理服务
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -245,113 +208,15 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border border-border bg-card/60 p-4 text-sm text-muted-foreground ${variantStyles[variant]}`}
|
className={`rounded-lg border border-white/10 bg-white/70 p-4 text-sm text-muted-foreground dark:bg-white/5 ${variantStyles[variant]}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||||||
{icon}
|
{icon}
|
||||||
<span className="text-xs">{label}</span>
|
<span className="text-xs font-medium uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-semibold text-foreground">{value}</p>
|
<p className="text-xl font-semibold text-foreground">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderQueueGroupProps {
|
|
||||||
appType: string;
|
|
||||||
appLabel: string;
|
|
||||||
targets: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
status: ProxyStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderQueueGroup({
|
|
||||||
appType,
|
|
||||||
appLabel,
|
|
||||||
targets,
|
|
||||||
status,
|
|
||||||
}: ProviderQueueGroupProps) {
|
|
||||||
// 查找该应用类型的当前活跃目标
|
|
||||||
const activeTarget = status.active_targets?.find(
|
|
||||||
(t) => t.app_type === appType,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 应用类型标题 */}
|
|
||||||
<div className="flex items-center gap-2 px-2">
|
|
||||||
<span className="text-xs font-semibold text-foreground/80">
|
|
||||||
{appLabel}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-px bg-border/50" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 供应商列表 */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{targets.map((target, index) => (
|
|
||||||
<ProviderQueueItem
|
|
||||||
key={target.id}
|
|
||||||
provider={target}
|
|
||||||
priority={index + 1}
|
|
||||||
appType={appType}
|
|
||||||
isCurrent={activeTarget?.provider_id === target.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderQueueItemProps {
|
|
||||||
provider: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
priority: number;
|
|
||||||
appType: string;
|
|
||||||
isCurrent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderQueueItem({
|
|
||||||
provider,
|
|
||||||
priority,
|
|
||||||
appType,
|
|
||||||
isCurrent,
|
|
||||||
}: ProviderQueueItemProps) {
|
|
||||||
const { data: health } = useProviderHealth(provider.id, appType);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm transition-colors ${
|
|
||||||
isCurrent
|
|
||||||
? "border-primary/40 bg-primary/10 text-primary font-medium"
|
|
||||||
: "border-border bg-background/60"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold ${
|
|
||||||
isCurrent
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{priority}
|
|
||||||
</span>
|
|
||||||
<span className={isCurrent ? "" : "text-foreground"}>
|
|
||||||
{provider.name}
|
|
||||||
</span>
|
|
||||||
{isCurrent && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
|
|
||||||
使用中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* 健康徽章 */}
|
|
||||||
<ProviderHealthBadge
|
|
||||||
consecutiveFailures={health?.consecutive_failures ?? 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* 代理模式切换开关组件
|
|
||||||
*
|
|
||||||
* 放置在主界面头部,用于一键启用/关闭代理模式
|
|
||||||
* 启用时自动接管 Live 配置,关闭时恢复原始配置
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Radio, Loader2 } from "lucide-react";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ProxyToggleProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProxyToggle({ className }: ProxyToggleProps) {
|
|
||||||
const {
|
|
||||||
isRunning,
|
|
||||||
isTakeoverActive,
|
|
||||||
startWithTakeover,
|
|
||||||
stopWithRestore,
|
|
||||||
isPending,
|
|
||||||
status,
|
|
||||||
} = useProxyStatus();
|
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
await startWithTakeover();
|
|
||||||
} else {
|
|
||||||
await stopWithRestore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActive = isRunning && isTakeoverActive;
|
|
||||||
|
|
||||||
const tooltipText = isActive
|
|
||||||
? `代理模式运行中 - ${status?.address}:${status?.port}\n切换供应商为热切换`
|
|
||||||
: "开启代理模式\n启用后自动接管 Live 配置";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
|
|
||||||
isActive
|
|
||||||
? "bg-emerald-500/10 border border-emerald-500/30"
|
|
||||||
: "bg-muted/50 hover:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
title={tooltipText}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Radio
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 transition-colors",
|
|
||||||
isActive
|
|
||||||
? "text-emerald-500 animate-pulse"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium transition-colors select-none",
|
|
||||||
isActive
|
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Proxy
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={isActive}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
disabled={isPending}
|
|
||||||
className="ml-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import { Download, ExternalLink, Info, Loader2, RefreshCw } from "lucide-react";
|
||||||
Download,
|
|
||||||
ExternalLink,
|
|
||||||
Info,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
Terminal,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
} 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";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -16,27 +7,16 @@ import { getVersion } from "@tauri-apps/api/app";
|
|||||||
import { settingsApi } from "@/lib/api";
|
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";
|
|
||||||
|
|
||||||
interface AboutSectionProps {
|
interface AboutSectionProps {
|
||||||
isPortable: boolean;
|
isPortable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolVersion {
|
|
||||||
name: string;
|
|
||||||
version: string | null;
|
|
||||||
latest_version: string | null;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AboutSection({ isPortable }: AboutSectionProps) {
|
export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||||
// ... (use hooks as before) ...
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
|
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
|
|
||||||
const [isLoadingTools, setIsLoadingTools] = useState(true);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
@@ -51,24 +31,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [appVersion, tools] = await Promise.all([
|
const loaded = await getVersion();
|
||||||
getVersion(),
|
|
||||||
settingsApi.getToolVersions(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
setVersion(appVersion);
|
setVersion(loaded);
|
||||||
setToolVersions(tools);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AboutSection] Failed to load info", error);
|
console.error("[AboutSection] Failed to get version", error);
|
||||||
if (active) {
|
if (active) {
|
||||||
setVersion(null);
|
setVersion(null);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (active) {
|
if (active) {
|
||||||
setIsLoadingVersion(false);
|
setIsLoadingVersion(false);
|
||||||
setIsLoadingTools(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -79,8 +53,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
|
|
||||||
|
|
||||||
const handleOpenReleaseNotes = useCallback(async () => {
|
const handleOpenReleaseNotes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const targetVersion = updateInfo?.availableVersion ?? version ?? "";
|
const targetVersion = updateInfo?.availableVersion ?? version ?? "";
|
||||||
@@ -142,7 +114,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
try {
|
try {
|
||||||
const available = await checkUpdate();
|
const available = await checkUpdate();
|
||||||
if (!available) {
|
if (!available) {
|
||||||
toast.success(t("settings.upToDate"), { closeButton: true });
|
toast.success(t("settings.upToDate"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AboutSection] Check update failed", error);
|
console.error("[AboutSection] Check update failed", error);
|
||||||
@@ -153,7 +125,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
const displayVersion = version ?? t("common.unknown");
|
const displayVersion = version ?? t("common.unknown");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-4">
|
||||||
<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,28 +133,24 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-6 space-y-6">
|
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||||
<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-1">
|
||||||
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
|
<p className="text-sm font-medium text-foreground">CC Switch</p>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Badge variant="outline" className="gap-1.5 bg-background">
|
{t("common.version")}{" "}
|
||||||
<span className="text-muted-foreground">
|
{isLoadingVersion ? (
|
||||||
{t("common.version")}
|
<Loader2 className="inline h-3 w-3 animate-spin" />
|
||||||
</span>
|
) : (
|
||||||
{isLoadingVersion ? (
|
`v${displayVersion}`
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<span className="font-medium">{`v${displayVersion}`}</span>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
{isPortable && (
|
|
||||||
<Badge variant="secondary" className="gap-1.5">
|
|
||||||
<Info className="h-3 w-3" />
|
|
||||||
{t("settings.portableMode")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</p>
|
||||||
|
{isPortable ? (
|
||||||
|
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
{t("settings.portableMode")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -191,7 +159,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleOpenReleaseNotes}
|
onClick={handleOpenReleaseNotes}
|
||||||
className="h-9"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
{t("settings.releaseNotes")}
|
{t("settings.releaseNotes")}
|
||||||
@@ -201,7 +168,7 @@ 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="min-w-[140px]"
|
||||||
>
|
>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
@@ -227,71 +194,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
</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">
|
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<p className="font-medium text-primary mb-1">
|
<p>
|
||||||
{t("settings.updateAvailable", {
|
{t("settings.updateAvailable", {
|
||||||
version: updateInfo.availableVersion,
|
version: updateInfo.availableVersion,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
{updateInfo.notes && (
|
{updateInfo.notes ? (
|
||||||
<p className="text-muted-foreground line-clamp-3 leading-relaxed">
|
<p className="mt-1 line-clamp-3">{updateInfo.notes}</p>
|
||||||
{updateInfo.notes}
|
) : null}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground px-1">
|
|
||||||
本地环境检查
|
|
||||||
</h4>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
{isLoadingTools
|
|
||||||
? Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-20 rounded-xl border border-border bg-card/50 animate-pulse"
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: toolVersions.map((tool) => (
|
|
||||||
<div
|
|
||||||
key={tool.name}
|
|
||||||
className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium capitalize">
|
|
||||||
{tool.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{tool.version ? (
|
|
||||||
<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 className="flex flex-col gap-0.5">
|
|
||||||
<div
|
|
||||||
className="text-xs font-mono truncate"
|
|
||||||
title={tool.version || tool.error || "Unknown"}
|
|
||||||
>
|
|
||||||
{tool.version ? tool.version : tool.error || "未安装"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function DirectorySettings({
|
|||||||
<Input
|
<Input
|
||||||
value={appConfigDir ?? resolvedDirs.appConfig ?? ""}
|
value={appConfigDir ?? resolvedDirs.appConfig ?? ""}
|
||||||
placeholder={t("settings.browsePlaceholderApp")}
|
placeholder={t("settings.browsePlaceholderApp")}
|
||||||
className="text-xs"
|
className="font-mono text-xs"
|
||||||
onChange={(event) => onAppConfigChange(event.target.value)}
|
onChange={(event) => onAppConfigChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -161,7 +161,7 @@ function DirectoryInput({
|
|||||||
<Input
|
<Input
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="text-xs"
|
className="font-mono text-xs"
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { Loader2, Save } from "lucide-react";
|
||||||
Loader2,
|
|
||||||
Save,
|
|
||||||
FolderSearch,
|
|
||||||
Activity,
|
|
||||||
Coins,
|
|
||||||
Database,
|
|
||||||
Server,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -18,12 +8,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { settingsApi } from "@/lib/api";
|
import { settingsApi } from "@/lib/api";
|
||||||
@@ -36,16 +20,11 @@ import { AboutSection } from "@/components/settings/AboutSection";
|
|||||||
import { ProxyPanel } from "@/components/proxy";
|
import { ProxyPanel } from "@/components/proxy";
|
||||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||||
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
|
|
||||||
import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager";
|
|
||||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||||
import { useSettings } from "@/hooks/useSettings";
|
import { useSettings } from "@/hooks/useSettings";
|
||||||
import { useImportExport } from "@/hooks/useImportExport";
|
import { useImportExport } from "@/hooks/useImportExport";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
interface SettingsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -138,7 +117,7 @@ export function SettingsPage({
|
|||||||
const handleRestartNow = useCallback(async () => {
|
const handleRestartNow = useCallback(async () => {
|
||||||
setShowRestartPrompt(false);
|
setShowRestartPrompt(false);
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
toast.success(t("settings.devModeRestartHint"), { closeButton: true });
|
toast.success(t("settings.devModeRestartHint"));
|
||||||
closeAfterSave();
|
closeAfterSave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,26 +154,6 @@ export function SettingsPage({
|
|||||||
|
|
||||||
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
|
||||||
|
|
||||||
const {
|
|
||||||
isRunning,
|
|
||||||
startWithTakeover: startProxy,
|
|
||||||
stopWithRestore: stopProxy,
|
|
||||||
isPending: isProxyPending,
|
|
||||||
} = useProxyStatus();
|
|
||||||
const [failoverEnabled, setFailoverEnabled] = useState(true);
|
|
||||||
|
|
||||||
const handleToggleProxy = async (checked: boolean) => {
|
|
||||||
try {
|
|
||||||
if (!checked) {
|
|
||||||
await stopProxy();
|
|
||||||
} else {
|
|
||||||
await startProxy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Toggle proxy failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
|
||||||
{isBusy ? (
|
{isBusy ? (
|
||||||
@@ -239,265 +198,61 @@ export function SettingsPage({
|
|||||||
|
|
||||||
<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">
|
<>
|
||||||
<Accordion
|
<DirectorySettings
|
||||||
type="multiple"
|
appConfigDir={appConfigDir}
|
||||||
defaultValue={[]}
|
resolvedDirs={resolvedDirs}
|
||||||
className="w-full space-y-4"
|
onAppConfigChange={updateAppConfigDir}
|
||||||
>
|
onBrowseAppConfig={browseAppConfigDir}
|
||||||
<AccordionItem
|
onResetAppConfig={resetAppConfigDir}
|
||||||
value="directory"
|
claudeDir={settings.claudeConfigDir}
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
codexDir={settings.codexConfigDir}
|
||||||
>
|
geminiDir={settings.geminiConfigDir}
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
onDirectoryChange={updateDirectory}
|
||||||
<div className="flex items-center gap-3">
|
onBrowseDirectory={browseDirectory}
|
||||||
<FolderSearch className="h-5 w-5 text-primary" />
|
onResetDirectory={resetDirectory}
|
||||||
<div className="text-left">
|
/>
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
配置文件目录
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
管理 Claude、Codex 和 Gemini 的配置存储路径
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<DirectorySettings
|
|
||||||
appConfigDir={appConfigDir}
|
|
||||||
resolvedDirs={resolvedDirs}
|
|
||||||
onAppConfigChange={updateAppConfigDir}
|
|
||||||
onBrowseAppConfig={browseAppConfigDir}
|
|
||||||
onResetAppConfig={resetAppConfigDir}
|
|
||||||
claudeDir={settings.claudeConfigDir}
|
|
||||||
codexDir={settings.codexConfigDir}
|
|
||||||
geminiDir={settings.geminiConfigDir}
|
|
||||||
onDirectoryChange={updateDirectory}
|
|
||||||
onBrowseDirectory={browseDirectory}
|
|
||||||
onResetDirectory={resetDirectory}
|
|
||||||
/>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
{/* 代理服务面板 */}
|
||||||
value="proxy"
|
<ProxyPanel />
|
||||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
|
||||||
>
|
|
||||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover: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">
|
|
||||||
<Server className="h-5 w-5 text-green-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
本地代理
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
控制代理服务开关、查看状态与端口信息
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pl-4">
|
{/* 模型定价配置 */}
|
||||||
<Badge
|
<PricingConfigPanel />
|
||||||
variant={isRunning ? "default" : "secondary"}
|
|
||||||
className="gap-1.5 h-6"
|
|
||||||
>
|
|
||||||
<Activity
|
|
||||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
|
||||||
/>
|
|
||||||
{isRunning ? "运行中" : "已停止"}
|
|
||||||
</Badge>
|
|
||||||
<Switch
|
|
||||||
checked={isRunning}
|
|
||||||
onCheckedChange={handleToggleProxy}
|
|
||||||
disabled={isProxyPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
|
|
||||||
<ProxyPanel />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
{/* 模型测试配置 */}
|
||||||
value="test"
|
<ModelTestConfigPanel />
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Activity className="h-5 w-5 text-indigo-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
模型测试配置
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
配置模型测试使用的默认模型和提示词
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<ModelTestConfigPanel />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
<ImportExportSection
|
||||||
value="failover"
|
status={importStatus}
|
||||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
selectedFile={selectedFile}
|
||||||
>
|
errorMessage={errorMessage}
|
||||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
backupId={backupId}
|
||||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
isImporting={isImporting}
|
||||||
<div className="flex items-center gap-3">
|
onSelectFile={selectImportFile}
|
||||||
<Activity className="h-5 w-5 text-orange-500" />
|
onImport={importConfig}
|
||||||
<div className="text-left">
|
onExport={exportConfig}
|
||||||
<h3 className="text-base font-semibold">
|
onClear={clearSelection}
|
||||||
自动故障转移
|
/>
|
||||||
</h3>
|
<div className="pt-6 border-t border-gray-200 dark:border-white/10">
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
配置故障转移队列和熔断策略
|
|
||||||
</p>
|
|
||||||
</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>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<div className="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>
|
|
||||||
<Tabs defaultValue="claude" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
|
||||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
|
||||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="claude" className="mt-4">
|
|
||||||
<FailoverQueueManager
|
|
||||||
appType="claude"
|
|
||||||
disabled={!failoverEnabled}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="codex" className="mt-4">
|
|
||||||
<FailoverQueueManager
|
|
||||||
appType="codex"
|
|
||||||
disabled={!failoverEnabled}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="gemini" className="mt-4">
|
|
||||||
<FailoverQueueManager
|
|
||||||
appType="gemini"
|
|
||||||
disabled={!failoverEnabled}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 熔断器配置 */}
|
|
||||||
<div className="border-t border-border/50 pt-6">
|
|
||||||
<AutoFailoverConfigPanel
|
|
||||||
enabled={failoverEnabled}
|
|
||||||
onEnabledChange={setFailoverEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="pricing"
|
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Coins className="h-5 w-5 text-yellow-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
成本定价
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
管理各模型 Token 计费规则
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<PricingConfigPanel />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem
|
|
||||||
value="data"
|
|
||||||
className="rounded-xl glass-card overflow-hidden"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Database className="h-5 w-5 text-blue-500" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
数据管理
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
|
||||||
导入导出配置与备份恢复
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
|
||||||
<ImportExportSection
|
|
||||||
status={importStatus}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
errorMessage={errorMessage}
|
|
||||||
backupId={backupId}
|
|
||||||
isImporting={isImporting}
|
|
||||||
onSelectFile={selectImportFile}
|
|
||||||
onImport={importConfig}
|
|
||||||
onExport={exportConfig}
|
|
||||||
onClear={clearSelection}
|
|
||||||
/>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="w-full h-12 text-base font-medium"
|
className="w-full"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t("settings.saving")}
|
{t("settings.saving")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="mr-2 h-5 w-5" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -516,7 +271,10 @@ export function SettingsPage({
|
|||||||
open={showRestartPrompt}
|
open={showRestartPrompt}
|
||||||
onOpenChange={(open) => !open && handleRestartLater()}
|
onOpenChange={(open) => !open && handleRestartLater()}
|
||||||
>
|
>
|
||||||
<DialogContent zIndex="alert" className="max-w-md glass border-border">
|
<DialogContent
|
||||||
|
zIndex="alert"
|
||||||
|
className="max-w-md glass border-white/10"
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
|
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -529,7 +287,7 @@ export function SettingsPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleRestartLater}
|
onClick={handleRestartLater}
|
||||||
className="hover:bg-muted/50"
|
className="hover:bg-white/5"
|
||||||
>
|
>
|
||||||
{t("settings.restartLater")}
|
{t("settings.restartLater")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SettingsFormState } from "@/hooks/useSettings";
|
import type { SettingsFormState } from "@/hooks/useSettings";
|
||||||
import { AppWindow, MonitorUp, Power } from "lucide-react";
|
|
||||||
|
|
||||||
interface WindowSettingsProps {
|
interface WindowSettingsProps {
|
||||||
settings: SettingsFormState;
|
settings: SettingsFormState;
|
||||||
@@ -13,46 +12,40 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pb-2 border-b border-border/40">
|
<header className="space-y-1">
|
||||||
<AppWindow className="h-4 w-4 text-primary" />
|
|
||||||
<h3 className="text-sm font-medium">{t("settings.windowBehavior")}</h3>
|
<h3 className="text-sm font-medium">{t("settings.windowBehavior")}</h3>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.windowBehaviorHint")}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<ToggleRow
|
||||||
<ToggleRow
|
title={t("settings.launchOnStartup")}
|
||||||
icon={<Power className="h-4 w-4 text-orange-500" />}
|
description={t("settings.launchOnStartupDescription")}
|
||||||
title={t("settings.launchOnStartup")}
|
checked={!!settings.launchOnStartup}
|
||||||
description={t("settings.launchOnStartupDescription")}
|
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
|
||||||
checked={!!settings.launchOnStartup}
|
/>
|
||||||
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
icon={<AppWindow className="h-4 w-4 text-blue-500" />}
|
title={t("settings.minimizeToTray")}
|
||||||
title={t("settings.minimizeToTray")}
|
description={t("settings.minimizeToTrayDescription")}
|
||||||
description={t("settings.minimizeToTrayDescription")}
|
checked={settings.minimizeToTrayOnClose}
|
||||||
checked={settings.minimizeToTrayOnClose}
|
onCheckedChange={(value) => onChange({ minimizeToTrayOnClose: value })}
|
||||||
onCheckedChange={(value) =>
|
/>
|
||||||
onChange({ minimizeToTrayOnClose: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
|
title={t("settings.enableClaudePluginIntegration")}
|
||||||
title={t("settings.enableClaudePluginIntegration")}
|
description={t("settings.enableClaudePluginIntegrationDescription")}
|
||||||
description={t("settings.enableClaudePluginIntegrationDescription")}
|
checked={!!settings.enableClaudePluginIntegration}
|
||||||
checked={!!settings.enableClaudePluginIntegration}
|
onCheckedChange={(value) =>
|
||||||
onCheckedChange={(value) =>
|
onChange({ enableClaudePluginIntegration: value })
|
||||||
onChange({ enableClaudePluginIntegration: value })
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToggleRowProps {
|
interface ToggleRowProps {
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
@@ -60,24 +53,18 @@ interface ToggleRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToggleRow({
|
function ToggleRow({
|
||||||
icon,
|
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
checked,
|
checked,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
}: ToggleRowProps) {
|
}: ToggleRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
|
<div className="flex items-start justify-between gap-4 rounded-lg border border-border-default p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-1">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
|
<p className="text-sm font-medium leading-none">{title}</p>
|
||||||
{icon}
|
{description ? (
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
<div className="space-y-1">
|
) : null}
|
||||||
<p className="text-sm font-medium leading-none">{title}</p>
|
|
||||||
{description ? (
|
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
|||||||
@@ -19,17 +19,11 @@ import { RefreshCw, Search } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { SkillCard } from "./SkillCard";
|
import { SkillCard } from "./SkillCard";
|
||||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||||
import {
|
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
|
||||||
skillsApi,
|
|
||||||
type Skill,
|
|
||||||
type SkillRepo,
|
|
||||||
type AppType,
|
|
||||||
} from "@/lib/api/skills";
|
|
||||||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||||||
|
|
||||||
interface SkillsPageProps {
|
interface SkillsPageProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
initialApp?: AppType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsPageHandle {
|
export interface SkillsPageHandle {
|
||||||
@@ -38,7 +32,7 @@ export interface SkillsPageHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||||
({ onClose: _onClose, initialApp = "claude" }, ref) => {
|
({ onClose: _onClose }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [skills, setSkills] = useState<Skill[]>([]);
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
const [repos, setRepos] = useState<SkillRepo[]>([]);
|
||||||
@@ -48,13 +42,11 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
const [filterStatus, setFilterStatus] = useState<
|
const [filterStatus, setFilterStatus] = useState<
|
||||||
"all" | "installed" | "uninstalled"
|
"all" | "installed" | "uninstalled"
|
||||||
>("all");
|
>("all");
|
||||||
// 使用 initialApp,不允许切换
|
|
||||||
const selectedApp = initialApp;
|
|
||||||
|
|
||||||
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await skillsApi.getAll(selectedApp);
|
const data = await skillsApi.getAll();
|
||||||
setSkills(data);
|
setSkills(data);
|
||||||
if (afterLoad) {
|
if (afterLoad) {
|
||||||
afterLoad(data);
|
afterLoad(data);
|
||||||
@@ -92,7 +84,6 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([loadSkills(), loadRepos()]);
|
Promise.all([loadSkills(), loadRepos()]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -102,10 +93,8 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
const handleInstall = async (directory: string) => {
|
const handleInstall = async (directory: string) => {
|
||||||
try {
|
try {
|
||||||
await skillsApi.install(directory, selectedApp);
|
await skillsApi.install(directory);
|
||||||
toast.success(t("skills.installSuccess", { name: directory }), {
|
toast.success(t("skills.installSuccess", { name: directory }));
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
await loadSkills();
|
await loadSkills();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -133,10 +122,8 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
|
|
||||||
const handleUninstall = async (directory: string) => {
|
const handleUninstall = async (directory: string) => {
|
||||||
try {
|
try {
|
||||||
await skillsApi.uninstall(directory, selectedApp);
|
await skillsApi.uninstall(directory);
|
||||||
toast.success(t("skills.uninstallSuccess", { name: directory }), {
|
toast.success(t("skills.uninstallSuccess", { name: directory }));
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
await loadSkills();
|
await loadSkills();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -184,15 +171,12 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
name: repo.name,
|
name: repo.name,
|
||||||
count: repoSkillCount,
|
count: repoSkillCount,
|
||||||
}),
|
}),
|
||||||
{ closeButton: true },
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveRepo = async (owner: string, name: string) => {
|
const handleRemoveRepo = async (owner: string, name: string) => {
|
||||||
await skillsApi.removeRepo(owner, name);
|
await skillsApi.removeRepo(owner, name);
|
||||||
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
|
toast.success(t("skills.repo.removeSuccess", { owner, name }));
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
await Promise.all([loadRepos(), loadSkills()]);
|
await Promise.all([loadRepos(), loadSkills()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Accordion = AccordionPrimitive.Root;
|
|
||||||
|
|
||||||
const AccordionItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn("border-b", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
AccordionItem.displayName = "AccordionItem";
|
|
||||||
|
|
||||||
const AccordionTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Header className="flex">
|
|
||||||
<AccordionPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
));
|
|
||||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
|
||||||
|
|
||||||
const AccordionContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
||||||
</AccordionPrimitive.Content>
|
|
||||||
));
|
|
||||||
|
|
||||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
|
||||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-border-default bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-md border border-border-default bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ModelStatsTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-hidden">
|
<div className="rounded-md bg-card/60 shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Save, Loader2 } from "lucide-react";
|
import { ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
getStreamCheckConfig,
|
getModelTestConfig,
|
||||||
saveStreamCheckConfig,
|
saveModelTestConfig,
|
||||||
type StreamCheckConfig,
|
type ModelTestConfig,
|
||||||
} from "@/lib/api/model-test";
|
} from "@/lib/api/model-test";
|
||||||
|
|
||||||
export function ModelTestConfigPanel() {
|
export function ModelTestConfigPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [config, setConfig] = useState<StreamCheckConfig>({
|
const [config, setConfig] = useState<ModelTestConfig>({
|
||||||
timeoutSecs: 45,
|
|
||||||
maxRetries: 2,
|
|
||||||
degradedThresholdMs: 6000,
|
|
||||||
claudeModel: "claude-haiku-4-5-20251001",
|
claudeModel: "claude-haiku-4-5-20251001",
|
||||||
codexModel: "gpt-5.1-codex@low",
|
codexModel: "gpt-5.1-low",
|
||||||
geminiModel: "gemini-3-pro-preview",
|
geminiModel: "gemini-3-pro-low",
|
||||||
|
testPrompt: "ping",
|
||||||
|
timeoutSecs: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,7 +41,7 @@ export function ModelTestConfigPanel() {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await getStreamCheckConfig();
|
const data = await getModelTestConfig();
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -46,13 +53,11 @@ export function ModelTestConfigPanel() {
|
|||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
await saveStreamCheckConfig(config);
|
await saveModelTestConfig(config);
|
||||||
toast.success(t("streamCheck.configSaved", "健康检查配置已保存"), {
|
toast.success(t("modelTest.configSaved", "模型测试配置已保存"));
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("streamCheck.configSaveFailed", "保存失败") + ": " + String(e),
|
t("modelTest.configSaveFailed", "保存失败") + ": " + String(e),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -61,151 +66,160 @@ export function ModelTestConfigPanel() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-4">
|
<Card className="border rounded-lg">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<CardHeader
|
||||||
</div>
|
className="cursor-pointer"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t("modelTest.configTitle", "模型测试配置")}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Card className="border rounded-lg">
|
||||||
{error && (
|
<CardHeader
|
||||||
<Alert variant="destructive">
|
className="cursor-pointer select-none"
|
||||||
<AlertDescription>{error}</AlertDescription>
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
</Alert>
|
>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded ? (
|
||||||
{/* 测试模型配置 */}
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
|
||||||
{t("streamCheck.testModels", "测试模型")}
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="claudeModel">
|
|
||||||
{t("streamCheck.claudeModel", "Claude 模型")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="claudeModel"
|
|
||||||
value={config.claudeModel}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfig({ ...config, claudeModel: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="claude-3-5-haiku-latest"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="codexModel">
|
|
||||||
{t("streamCheck.codexModel", "Codex 模型")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="codexModel"
|
|
||||||
value={config.codexModel}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfig({ ...config, codexModel: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="gpt-4o-mini"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="geminiModel">
|
|
||||||
{t("streamCheck.geminiModel", "Gemini 模型")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="geminiModel"
|
|
||||||
value={config.geminiModel}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfig({ ...config, geminiModel: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="gemini-1.5-flash"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 检查参数配置 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
|
||||||
{t("streamCheck.checkParams", "检查参数")}
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="timeoutSecs">
|
|
||||||
{t("streamCheck.timeout", "超时时间(秒)")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="timeoutSecs"
|
|
||||||
type="number"
|
|
||||||
min={10}
|
|
||||||
max={120}
|
|
||||||
value={config.timeoutSecs}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
timeoutSecs: parseInt(e.target.value) || 45,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="maxRetries">
|
|
||||||
{t("streamCheck.maxRetries", "最大重试次数")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="maxRetries"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={5}
|
|
||||||
value={config.maxRetries}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
maxRetries: parseInt(e.target.value) || 2,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="degradedThresholdMs">
|
|
||||||
{t("streamCheck.degradedThreshold", "降级阈值(毫秒)")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="degradedThresholdMs"
|
|
||||||
type="number"
|
|
||||||
min={1000}
|
|
||||||
max={30000}
|
|
||||||
step={1000}
|
|
||||||
value={config.degradedThresholdMs}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
degradedThresholdMs: parseInt(e.target.value) || 6000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={handleSave} disabled={isSaving}>
|
|
||||||
{isSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t("common.saving", "保存中...")}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
{t("common.save", "保存")}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
<div>
|
||||||
</div>
|
<CardTitle className="text-base">
|
||||||
</div>
|
{t("modelTest.configTitle", "模型测试配置")}
|
||||||
|
</CardTitle>
|
||||||
|
{!isExpanded && (
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
{t(
|
||||||
|
"modelTest.configDesc",
|
||||||
|
"配置模型测试使用的默认模型和提示词",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="claudeModel">
|
||||||
|
{t("modelTest.claudeModel", "Claude 测试模型")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="claudeModel"
|
||||||
|
value={config.claudeModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, claudeModel: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="claude-haiku-4-5-20251001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="codexModel">
|
||||||
|
{t("modelTest.codexModel", "Codex 测试模型")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="codexModel"
|
||||||
|
value={config.codexModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, codexModel: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="gpt-5.1-low"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="geminiModel">
|
||||||
|
{t("modelTest.geminiModel", "Gemini 测试模型")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="geminiModel"
|
||||||
|
value={config.geminiModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, geminiModel: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="gemini-3-pro-low"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="testPrompt">
|
||||||
|
{t("modelTest.testPrompt", "测试提示词")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="testPrompt"
|
||||||
|
value={config.testPrompt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({ ...config, testPrompt: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="ping"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"modelTest.testPromptHint",
|
||||||
|
"发送给模型的测试消息,建议使用简短内容以减少 token 消耗",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timeoutSecs">
|
||||||
|
{t("modelTest.timeout", "超时时间(秒)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="timeoutSecs"
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={60}
|
||||||
|
value={config.timeoutSecs}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
timeoutSecs: parseInt(e.target.value) || 15,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("common.saving", "保存中...")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.save", "保存")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -104,107 +110,136 @@ export function PricingConfigPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Card className="border rounded-lg">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<CardHeader
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
className="cursor-pointer select-none"
|
||||||
{t("usage.modelPricingDesc", "配置各模型的 Token 成本")} (每百万)
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
</h4>
|
>
|
||||||
<Button
|
<div className="flex items-center justify-between">
|
||||||
onClick={(e) => {
|
<div className="flex items-center gap-2">
|
||||||
e.stopPropagation();
|
{isExpanded ? (
|
||||||
handleAddNew();
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
}}
|
) : (
|
||||||
size="sm"
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
>
|
)}
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<div>
|
||||||
{t("common.add", "新增")}
|
<CardTitle className="text-base">
|
||||||
</Button>
|
{t("usage.modelPricing", "模型定价")}
|
||||||
</div>
|
{pricing && pricing.length > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||||
<div className="space-y-4">
|
({pricing.length})
|
||||||
{!pricing || pricing.length === 0 ? (
|
</span>
|
||||||
<Alert>
|
)}
|
||||||
<AlertDescription>
|
</CardTitle>
|
||||||
{t(
|
{!isExpanded && (
|
||||||
"usage.noPricingData",
|
<CardDescription className="mt-1">
|
||||||
'暂无定价数据。点击"新增"添加模型定价配置。',
|
{t(
|
||||||
|
"usage.modelPricingDesc",
|
||||||
|
"配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</div>
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md bg-card/60 shadow-sm">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{t("usage.model", "模型")}</TableHead>
|
|
||||||
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("usage.inputCost", "输入成本")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("usage.outputCost", "输出成本")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("usage.cacheReadCost", "缓存读取")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("usage.cacheWriteCost", "缓存写入")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("common.actions", "操作")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{pricing.map((model) => (
|
|
||||||
<TableRow key={model.modelId}>
|
|
||||||
<TableCell className="font-mono text-sm">
|
|
||||||
{model.modelId}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{model.displayName}</TableCell>
|
|
||||||
<TableCell className="text-right font-mono text-sm">
|
|
||||||
${model.inputCostPerMillion}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-mono text-sm">
|
|
||||||
${model.outputCostPerMillion}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-mono text-sm">
|
|
||||||
${model.cacheReadCostPerMillion}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-mono text-sm">
|
|
||||||
${model.cacheCreationCostPerMillion}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddingNew(false);
|
|
||||||
setEditingModel(model);
|
|
||||||
}}
|
|
||||||
title={t("common.edit", "编辑")}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setDeleteConfirm(model.modelId)}
|
|
||||||
title={t("common.delete", "删除")}
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Button
|
||||||
</div>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleAddNew();
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
{t("common.add", "新增")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<CardContent>
|
||||||
|
{!pricing || pricing.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
{t(
|
||||||
|
"usage.noPricingData",
|
||||||
|
'暂无定价数据。点击"新增"添加模型定价配置。',
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md bg-card/60 shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("usage.model", "模型")}</TableHead>
|
||||||
|
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("usage.inputCost", "输入成本")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("usage.outputCost", "输出成本")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("usage.cacheReadCost", "缓存读取")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("usage.cacheWriteCost", "缓存写入")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("common.actions", "操作")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pricing.map((model) => (
|
||||||
|
<TableRow key={model.modelId}>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{model.modelId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{model.displayName}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
${model.inputCostPerMillion}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
${model.outputCostPerMillion}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
${model.cacheReadCostPerMillion}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
${model.cacheCreationCostPerMillion}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAddingNew(false);
|
||||||
|
setEditingModel(model);
|
||||||
|
}}
|
||||||
|
title={t("common.edit", "编辑")}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDeleteConfirm(model.modelId)}
|
||||||
|
title={t("common.delete", "删除")}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingModel && (
|
{editingModel && (
|
||||||
<PricingEditModal
|
<PricingEditModal
|
||||||
@@ -249,6 +284,6 @@ export function PricingConfigPanel() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export function PricingEditModal({
|
|||||||
isNew
|
isNew
|
||||||
? t("usage.pricingAdded", "定价已添加")
|
? t("usage.pricingAdded", "定价已添加")
|
||||||
: t("usage.pricingUpdated", "定价已更新"),
|
: t("usage.pricingUpdated", "定价已更新"),
|
||||||
{ closeButton: true },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ProviderStatsTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-hidden">
|
<div className="rounded-md bg-card/60 shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -65,151 +65,123 @@ export function RequestLogTable() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 筛选栏 */}
|
{/* 筛选栏 */}
|
||||||
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
|
<div className="flex flex-wrap items-center gap-2 rounded-md bg-card/60 p-3 shadow-sm">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Select
|
||||||
<Select
|
value={tempFilters.appType || "all"}
|
||||||
value={tempFilters.appType || "all"}
|
onValueChange={(v) =>
|
||||||
onValueChange={(v) =>
|
setTempFilters({
|
||||||
setTempFilters({
|
...tempFilters,
|
||||||
...tempFilters,
|
appType: v === "all" ? undefined : v,
|
||||||
appType: v === "all" ? undefined : v,
|
})
|
||||||
})
|
}
|
||||||
}
|
>
|
||||||
>
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectTrigger className="w-[130px] bg-background">
|
<SelectValue placeholder={t("usage.endpoint", "端点")} />
|
||||||
<SelectValue placeholder={t("usage.endpoint", "端点")} />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
|
||||||
<SelectItem value="all">{t("common.all", "全部端点")}</SelectItem>
|
<SelectItem value="claude">Claude</SelectItem>
|
||||||
<SelectItem value="claude">Claude</SelectItem>
|
<SelectItem value="codex">Codex</SelectItem>
|
||||||
<SelectItem value="codex">Codex</SelectItem>
|
<SelectItem value="gemini">Gemini</SelectItem>
|
||||||
<SelectItem value="gemini">Gemini</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={tempFilters.statusCode?.toString() || "all"}
|
value={tempFilters.statusCode?.toString() || "all"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setTempFilters({
|
setTempFilters({
|
||||||
...tempFilters,
|
...tempFilters,
|
||||||
statusCode: v === "all" ? undefined : parseInt(v),
|
statusCode: v === "all" ? undefined : parseInt(v),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[130px] bg-background">
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectValue placeholder={t("usage.status", "状态码")} />
|
<SelectValue placeholder={t("usage.status", "状态码")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t("common.all", "全部状态")}</SelectItem>
|
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
|
||||||
<SelectItem value="200">200 OK</SelectItem>
|
<SelectItem value="200">200</SelectItem>
|
||||||
<SelectItem value="400">400 Bad Request</SelectItem>
|
<SelectItem value="400">400</SelectItem>
|
||||||
<SelectItem value="401">401 Unauthorized</SelectItem>
|
<SelectItem value="401">401</SelectItem>
|
||||||
<SelectItem value="429">429 Rate Limit</SelectItem>
|
<SelectItem value="429">429</SelectItem>
|
||||||
<SelectItem value="500">500 Server Error</SelectItem>
|
<SelectItem value="500">500</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
<Input
|
||||||
<div className="relative flex-1">
|
placeholder={t("usage.provider", "供应商名称")}
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
className="w-[140px]"
|
||||||
<Input
|
value={tempFilters.providerName || ""}
|
||||||
placeholder={t("usage.provider", "搜索供应商...")}
|
onChange={(e) =>
|
||||||
className="pl-9 bg-background"
|
setTempFilters({
|
||||||
value={tempFilters.providerName || ""}
|
...tempFilters,
|
||||||
onChange={(e) =>
|
providerName: e.target.value || undefined,
|
||||||
setTempFilters({
|
})
|
||||||
...tempFilters,
|
}
|
||||||
providerName: e.target.value || undefined,
|
/>
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder={t("usage.model", "搜索模型...")}
|
|
||||||
className="w-[180px] bg-background"
|
|
||||||
value={tempFilters.model || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTempFilters({
|
|
||||||
...tempFilters,
|
|
||||||
model: e.target.value || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Input
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
placeholder={t("usage.model", "模型名称")}
|
||||||
<span className="whitespace-nowrap">时间范围:</span>
|
className="w-[140px]"
|
||||||
<Input
|
value={tempFilters.model || ""}
|
||||||
type="datetime-local"
|
onChange={(e) =>
|
||||||
className="h-8 w-[200px] bg-background"
|
setTempFilters({
|
||||||
value={
|
...tempFilters,
|
||||||
tempFilters.startDate
|
model: e.target.value || undefined,
|
||||||
? new Date(tempFilters.startDate * 1000)
|
})
|
||||||
.toISOString()
|
}
|
||||||
.slice(0, 16)
|
/>
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTempFilters({
|
|
||||||
...tempFilters,
|
|
||||||
startDate: e.target.value
|
|
||||||
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>-</span>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
className="h-8 w-[200px] bg-background"
|
|
||||||
value={
|
|
||||||
tempFilters.endDate
|
|
||||||
? new Date(tempFilters.endDate * 1000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 16)
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTempFilters({
|
|
||||||
...tempFilters,
|
|
||||||
endDate: e.target.value
|
|
||||||
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<Input
|
||||||
<Button
|
type="datetime-local"
|
||||||
size="sm"
|
className="w-[180px]"
|
||||||
variant="default"
|
value={
|
||||||
onClick={handleSearch}
|
tempFilters.startDate
|
||||||
className="h-8"
|
? new Date(tempFilters.startDate * 1000)
|
||||||
>
|
.toISOString()
|
||||||
<Search className="mr-2 h-3.5 w-3.5" />
|
.slice(0, 16)
|
||||||
{t("common.search", "查询")}
|
: ""
|
||||||
</Button>
|
}
|
||||||
<Button
|
onChange={(e) =>
|
||||||
size="sm"
|
setTempFilters({
|
||||||
variant="outline"
|
...tempFilters,
|
||||||
onClick={handleReset}
|
startDate: e.target.value
|
||||||
className="h-8"
|
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||||
>
|
: undefined,
|
||||||
<X className="mr-2 h-3.5 w-3.5" />
|
})
|
||||||
{t("common.reset", "重置")}
|
}
|
||||||
</Button>
|
/>
|
||||||
<Button
|
|
||||||
size="sm"
|
<Input
|
||||||
variant="ghost"
|
type="datetime-local"
|
||||||
onClick={handleRefresh}
|
className="w-[180px]"
|
||||||
className="h-8 px-2"
|
value={
|
||||||
>
|
tempFilters.endDate
|
||||||
<RefreshCw className="h-4 w-4" />
|
? new Date(tempFilters.endDate * 1000).toISOString().slice(0, 16)
|
||||||
</Button>
|
: ""
|
||||||
</div>
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTempFilters({
|
||||||
|
...tempFilters,
|
||||||
|
endDate: e.target.value
|
||||||
|
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleSearch}>
|
||||||
|
<Search className="mr-1 h-4 w-4" />
|
||||||
|
{t("common.search", "查询")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleReset}>
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
{t("common.reset", "重置")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,40 +189,34 @@ export function RequestLogTable() {
|
|||||||
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
|
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-x-auto">
|
<div className="rounded-md bg-card/60 shadow-sm overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead>{t("usage.time", "时间")}</TableHead>
|
||||||
{t("usage.time", "时间")}
|
<TableHead>{t("usage.provider", "供应商")}</TableHead>
|
||||||
</TableHead>
|
<TableHead className="min-w-[280px]">
|
||||||
<TableHead className="whitespace-nowrap">
|
|
||||||
{t("usage.provider", "供应商")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[280px] whitespace-nowrap">
|
|
||||||
{t("usage.billingModel", "计费模型")}
|
{t("usage.billingModel", "计费模型")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right">
|
||||||
{t("usage.inputTokens", "输入")}
|
{t("usage.inputTokens", "输入")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right">
|
||||||
{t("usage.outputTokens", "输出")}
|
{t("usage.outputTokens", "输出")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
<TableHead className="text-right min-w-[90px]">
|
||||||
{t("usage.cacheReadTokens", "缓存读取")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
|
||||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
{t("usage.cacheCreationTokens", "缓存写入")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right min-w-[90px]">
|
||||||
|
{t("usage.cacheReadTokens", "缓存读取")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
{t("usage.totalCost", "成本")}
|
{t("usage.totalCost", "成本")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
<TableHead className="text-center min-w-[140px]">
|
||||||
{t("usage.timingInfo", "用时/首字")}
|
{t("usage.timingInfo", "用时/首字")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead>{t("usage.status", "状态")}</TableHead>
|
||||||
{t("usage.status", "状态")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -286,10 +252,10 @@ export function RequestLogTable() {
|
|||||||
{log.outputTokens.toLocaleString()}
|
{log.outputTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.cacheReadTokens.toLocaleString()}
|
{log.cacheCreationTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{log.cacheCreationTokens.toLocaleString()}
|
{log.cacheReadTokens.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { UsageSummaryCards } from "./UsageSummaryCards";
|
import { UsageSummaryCards } from "./UsageSummaryCards";
|
||||||
import { UsageTrendChart } from "./UsageTrendChart";
|
import { UsageTrendChart } from "./UsageTrendChart";
|
||||||
import { RequestLogTable } from "./RequestLogTable";
|
import { RequestLogTable } from "./RequestLogTable";
|
||||||
import { ProviderStatsTable } from "./ProviderStatsTable";
|
import { ProviderStatsTable } from "./ProviderStatsTable";
|
||||||
import { ModelStatsTable } from "./ModelStatsTable";
|
import { ModelStatsTable } from "./ModelStatsTable";
|
||||||
import type { TimeRange } from "@/types/usage";
|
import type { TimeRange } from "@/types/usage";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { BarChart3, ListFilter, Activity } from "lucide-react";
|
|
||||||
|
|
||||||
export function UsageDashboard() {
|
export function UsageDashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -17,90 +16,50 @@ export function UsageDashboard() {
|
|||||||
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="space-y-6">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<div className="flex items-center justify-end">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<select
|
||||||
transition={{ duration: 0.4 }}
|
|
||||||
className="space-y-8 pb-8"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h2 className="text-2xl font-bold">{t("usage.title", "使用统计")}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("usage.subtitle", "查看 AI 模型的使用情况和成本统计")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onValueChange={(v) => setTimeRange(v as TimeRange)}
|
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
||||||
className="w-full sm:w-auto"
|
className="rounded-md border px-3 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
|
<option value="1d">{t("usage.today", "今天")}</option>
|
||||||
<TabsTrigger
|
<option value="7d">{t("usage.last7days", "过去 7 天")}</option>
|
||||||
value="1d"
|
<option value="30d">{t("usage.last30days", "过去 30 天")}</option>
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
</select>
|
||||||
>
|
|
||||||
{t("usage.today", "24小时")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="7d"
|
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{t("usage.last7days", "7天")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="30d"
|
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{t("usage.last30days", "30天")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UsageSummaryCards days={days} />
|
<UsageSummaryCards days={days} />
|
||||||
|
|
||||||
<UsageTrendChart days={days} />
|
<Card className="border-none bg-transparent p-0 shadow-none">
|
||||||
|
<UsageTrendChart days={days} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<Tabs defaultValue="logs" className="w-full">
|
||||||
<Tabs defaultValue="logs" className="w-full">
|
<TabsList>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<TabsTrigger value="logs">
|
||||||
<TabsList className="bg-muted/50">
|
{t("usage.requestLogs", "请求日志")}
|
||||||
<TabsTrigger value="logs" className="gap-2">
|
</TabsTrigger>
|
||||||
<ListFilter className="h-4 w-4" />
|
<TabsTrigger value="providers">
|
||||||
{t("usage.requestLogs", "请求日志")}
|
{t("usage.providerStats", "Provider 统计")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="providers" className="gap-2">
|
<TabsTrigger value="models">
|
||||||
<Activity className="h-4 w-4" />
|
{t("usage.modelStats", "模型统计")}
|
||||||
{t("usage.providerStats", "Provider 统计")}
|
</TabsTrigger>
|
||||||
</TabsTrigger>
|
</TabsList>
|
||||||
<TabsTrigger value="models" className="gap-2">
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
{t("usage.modelStats", "模型统计")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
<TabsContent value="logs" className="mt-4">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<RequestLogTable />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</TabsContent>
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<TabsContent value="logs" className="mt-0">
|
|
||||||
<RequestLogTable />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="providers" className="mt-0">
|
<TabsContent value="providers" className="mt-4">
|
||||||
<ProviderStatsTable />
|
<ProviderStatsTable />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="models" className="mt-0">
|
<TabsContent value="models" className="mt-4">
|
||||||
<ModelStatsTable />
|
<ModelStatsTable />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</motion.div>
|
</Tabs>
|
||||||
</Tabs>
|
</div>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { useUsageSummary } from "@/lib/query/usage";
|
import { useUsageSummary } from "@/lib/query/usage";
|
||||||
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
interface UsageSummaryCardsProps {
|
interface UsageSummaryCardsProps {
|
||||||
days: number;
|
days: number;
|
||||||
@@ -11,118 +8,29 @@ interface UsageSummaryCardsProps {
|
|||||||
|
|
||||||
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const endDate = Math.floor(Date.now() / 1000);
|
||||||
const { startDate, endDate } = useMemo(() => {
|
const startDate = endDate - days * 24 * 60 * 60;
|
||||||
const end = Math.floor(Date.now() / 1000);
|
|
||||||
const start = end - days * 24 * 60 * 60;
|
|
||||||
return { startDate: start, endDate: end };
|
|
||||||
}, [days]);
|
|
||||||
|
|
||||||
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
|
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
|
||||||
|
const totalRequests = summary?.totalRequests ?? 0;
|
||||||
const stats = useMemo(() => {
|
const totalCost = parseFloat(summary?.totalCost || "0").toFixed(4);
|
||||||
const totalRequests = summary?.totalRequests ?? 0;
|
const totalInputTokens = summary?.totalInputTokens ?? 0;
|
||||||
const totalCost = parseFloat(summary?.totalCost || "0");
|
const totalOutputTokens = summary?.totalOutputTokens ?? 0;
|
||||||
|
const totalTokens = totalInputTokens + totalOutputTokens;
|
||||||
const inputTokens = summary?.totalInputTokens ?? 0;
|
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
|
||||||
const outputTokens = summary?.totalOutputTokens ?? 0;
|
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
|
||||||
const totalTokens = inputTokens + outputTokens;
|
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
|
||||||
|
|
||||||
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
|
|
||||||
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
|
|
||||||
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: t("usage.totalRequests", "总请求数"),
|
|
||||||
value: totalRequests.toLocaleString(),
|
|
||||||
icon: Activity,
|
|
||||||
color: "text-blue-500",
|
|
||||||
bg: "bg-blue-500/10",
|
|
||||||
subValue: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("usage.totalCost", "总成本"),
|
|
||||||
value: `$${totalCost.toFixed(4)}`,
|
|
||||||
icon: DollarSign,
|
|
||||||
color: "text-green-500",
|
|
||||||
bg: "bg-green-500/10",
|
|
||||||
subValue: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("usage.totalTokens", "总 Token 数"),
|
|
||||||
value: totalTokens.toLocaleString(),
|
|
||||||
icon: Layers,
|
|
||||||
color: "text-purple-500",
|
|
||||||
bg: "bg-purple-500/10",
|
|
||||||
subValue: (
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span>Input</span>
|
|
||||||
<span className="text-foreground/80">
|
|
||||||
{(inputTokens / 1000).toFixed(1)}k
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span>Output</span>
|
|
||||||
<span className="text-foreground/80">
|
|
||||||
{(outputTokens / 1000).toFixed(1)}k
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("usage.cacheTokens", "缓存 Token"),
|
|
||||||
value: totalCacheTokens.toLocaleString(),
|
|
||||||
icon: Database,
|
|
||||||
color: "text-orange-500",
|
|
||||||
bg: "bg-orange-500/10",
|
|
||||||
subValue: (
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span>Write</span>
|
|
||||||
<span className="text-foreground/80">
|
|
||||||
{(cacheWriteTokens / 1000).toFixed(1)}k
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span>Read</span>
|
|
||||||
<span className="text-foreground/80">
|
|
||||||
{(cacheReadTokens / 1000).toFixed(1)}k
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [summary, t]);
|
|
||||||
|
|
||||||
const container = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
show: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const item = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
show: { opacity: 1, y: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<Card
|
<Card key={i}>
|
||||||
key={i}
|
<CardHeader className="pb-2">
|
||||||
className="border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm"
|
<div className="h-4 w-24 animate-pulse rounded bg-gray-200" />
|
||||||
>
|
</CardHeader>
|
||||||
<CardContent className="p-6 flex items-center justify-center min-h-[160px]">
|
<CardContent>
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
|
<div className="h-8 w-32 animate-pulse rounded bg-gray-200" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -131,39 +39,75 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
variants={container}
|
<Card>
|
||||||
initial="hidden"
|
<CardHeader className="pb-2">
|
||||||
animate="show"
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
className="grid gap-4 md:grid-cols-4"
|
{t("usage.totalRequests", "总请求数")}
|
||||||
>
|
</CardTitle>
|
||||||
{stats.map((stat, i) => (
|
</CardHeader>
|
||||||
<motion.div key={i} variants={item}>
|
<CardContent>
|
||||||
<Card className="relative h-full overflow-hidden border border-border/50 bg-gradient-to-br from-card/50 to-background/50 backdrop-blur-xl hover:from-card/60 hover:to-background/60 transition-all shadow-sm">
|
<div className="text-2xl font-bold">
|
||||||
<CardContent className="p-5">
|
{totalRequests.toLocaleString()}
|
||||||
<div className="flex items-start justify-between mb-2">
|
</div>
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
</CardContent>
|
||||||
{stat.title}
|
</Card>
|
||||||
</p>
|
|
||||||
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
|
||||||
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<Card>
|
||||||
<h3 className="text-2xl font-bold truncate" title={stat.value}>
|
<CardHeader className="pb-2">
|
||||||
{stat.value}
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
</h3>
|
{t("usage.totalCost", "总成本")}
|
||||||
</div>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">${totalCost}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{stat.subValue || (
|
<Card>
|
||||||
/* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */
|
<CardHeader className="pb-2">
|
||||||
<div className="mt-3 pt-3 border-t border-transparent h-[52px]"></div>
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
)}
|
{t("usage.totalTokens", "总 Token 数")}
|
||||||
</CardContent>
|
</CardTitle>
|
||||||
</Card>
|
</CardHeader>
|
||||||
</motion.div>
|
<CardContent>
|
||||||
))}
|
<div className="text-2xl font-bold">
|
||||||
</motion.div>
|
{totalTokens.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
{t("usage.inputTokens", "输入")}:{" "}
|
||||||
|
{totalInputTokens.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("usage.outputTokens", "输出")}:{" "}
|
||||||
|
{totalOutputTokens.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("usage.cacheTokens", "缓存 Token")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{totalCacheTokens.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
{t("usage.cacheWrite", "写入")}:{" "}
|
||||||
|
{cacheWriteTokens.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
LineChart,
|
||||||
Area,
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useUsageTrends } from "@/lib/query/usage";
|
import { useUsageTrends } from "@/lib/query/usage";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface UsageTrendChartProps {
|
interface UsageTrendChartProps {
|
||||||
days: number;
|
days: number;
|
||||||
@@ -21,11 +20,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
const { data: trends, isLoading } = useUsageTrends(days);
|
const { data: trends, isLoading } = useUsageTrends(days);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <div className="h-[320px] animate-pulse rounded bg-gray-100" />;
|
||||||
<div className="flex h-[350px] items-center justify-center rounded-xl bg-card/40 border border-border/50">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isToday = days === 1;
|
const isToday = days === 1;
|
||||||
@@ -43,6 +38,8 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
hour: pointDate.getHours(),
|
hour: pointDate.getHours(),
|
||||||
inputTokens: stat.totalInputTokens,
|
inputTokens: stat.totalInputTokens,
|
||||||
outputTokens: stat.totalOutputTokens,
|
outputTokens: stat.totalOutputTokens,
|
||||||
|
cacheCreationTokens: stat.totalCacheCreationTokens,
|
||||||
|
cacheReadTokens: stat.totalCacheReadTokens,
|
||||||
cost: parseFloat(stat.totalCost),
|
cost: parseFloat(stat.totalCost),
|
||||||
};
|
};
|
||||||
}) || [];
|
}) || [];
|
||||||
@@ -59,6 +56,8 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
label: `${hour.toString().padStart(2, "0")}:00`,
|
label: `${hour.toString().padStart(2, "0")}:00`,
|
||||||
inputTokens: bucket?.inputTokens ?? 0,
|
inputTokens: bucket?.inputTokens ?? 0,
|
||||||
outputTokens: bucket?.outputTokens ?? 0,
|
outputTokens: bucket?.outputTokens ?? 0,
|
||||||
|
cacheCreationTokens: bucket?.cacheCreationTokens ?? 0,
|
||||||
|
cacheReadTokens: bucket?.cacheReadTokens ?? 0,
|
||||||
cost: bucket?.cost ?? 0,
|
cost: bucket?.cost ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -66,129 +65,96 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
|
|
||||||
const displayData = isToday ? hourlyData : chartData;
|
const displayData = isToday ? hourlyData : chartData;
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
const rangeLabel = isToday
|
||||||
if (active && payload && payload.length) {
|
? t("usage.rangeToday", "今天 (按小时)")
|
||||||
return (
|
: days === 7
|
||||||
<div className="rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-md">
|
? t("usage.rangeLast7Days", "过去 7 天")
|
||||||
<p className="mb-2 font-medium">{label}</p>
|
: t("usage.rangeLast30Days", "过去 30 天");
|
||||||
{payload.map((entry: any, index: number) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center gap-2 text-sm"
|
|
||||||
style={{ color: entry.color }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: entry.color }}
|
|
||||||
/>
|
|
||||||
<span className="font-medium">{entry.name}:</span>
|
|
||||||
<span>
|
|
||||||
{entry.name.includes(t("usage.cost", "成本"))
|
|
||||||
? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}`
|
|
||||||
: entry.value.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border/50 bg-card/40 p-6 backdrop-blur-sm">
|
<div className="space-y-4">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">
|
||||||
{t("usage.trends", "使用趋势")}
|
{t("usage.trends", "使用趋势")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
||||||
{isToday
|
|
||||||
? t("usage.rangeToday", "今天 (按小时)")
|
|
||||||
: days === 7
|
|
||||||
? t("usage.rangeLast7Days", "过去 7 天")
|
|
||||||
: t("usage.rangeLast30Days", "过去 30 天")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[350px] w-full">
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<LineChart data={displayData}>
|
||||||
<AreaChart
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
data={displayData}
|
<XAxis dataKey="label" />
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
<YAxis
|
||||||
>
|
yAxisId="tokens"
|
||||||
<defs>
|
label={{
|
||||||
<linearGradient id="colorInput" x1="0" y1="0" x2="0" y2="1">
|
value: t("usage.tokensAxis", "Tokens"),
|
||||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
|
angle: -90,
|
||||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
position: "insideLeft",
|
||||||
</linearGradient>
|
}}
|
||||||
<linearGradient id="colorOutput" x1="0" y1="0" x2="0" y2="1">
|
/>
|
||||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
|
<YAxis
|
||||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
|
yAxisId="cost"
|
||||||
</linearGradient>
|
orientation="right"
|
||||||
</defs>
|
label={{
|
||||||
<CartesianGrid
|
value: t("usage.costAxis", "成本 (USD)"),
|
||||||
strokeDasharray="3 3"
|
angle: 90,
|
||||||
vertical={false}
|
position: "insideRight",
|
||||||
stroke="hsl(var(--border))"
|
}}
|
||||||
opacity={0.4}
|
/>
|
||||||
/>
|
<Tooltip />
|
||||||
<XAxis
|
<Legend />
|
||||||
dataKey="label"
|
<Line
|
||||||
axisLine={false}
|
yAxisId="tokens"
|
||||||
tickLine={false}
|
type="monotone"
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
|
dataKey="inputTokens"
|
||||||
dy={10}
|
name={t("usage.inputTokens", "输入 Tokens")}
|
||||||
/>
|
stroke="#2563eb"
|
||||||
<YAxis
|
strokeWidth={2}
|
||||||
yAxisId="tokens"
|
dot={false}
|
||||||
axisLine={false}
|
isAnimationActive
|
||||||
tickLine={false}
|
/>
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
|
<Line
|
||||||
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
yAxisId="tokens"
|
||||||
/>
|
type="monotone"
|
||||||
<YAxis
|
dataKey="outputTokens"
|
||||||
yAxisId="cost"
|
name={t("usage.outputTokens", "输出 Tokens")}
|
||||||
orientation="right"
|
stroke="#16a34a"
|
||||||
axisLine={false}
|
strokeWidth={2}
|
||||||
tickLine={false}
|
dot={false}
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
|
isAnimationActive
|
||||||
tickFormatter={(value) => `$${value}`}
|
/>
|
||||||
/>
|
<Line
|
||||||
<Tooltip content={<CustomTooltip />} />
|
yAxisId="tokens"
|
||||||
<Legend />
|
type="monotone"
|
||||||
<Area
|
dataKey="cacheCreationTokens"
|
||||||
yAxisId="tokens"
|
name={t("usage.cacheCreationTokens", "缓存写入")}
|
||||||
type="monotone"
|
stroke="#f97316"
|
||||||
dataKey="inputTokens"
|
strokeWidth={2}
|
||||||
name={t("usage.inputTokens", "输入 Tokens")}
|
dot={false}
|
||||||
stroke="#3b82f6"
|
isAnimationActive
|
||||||
fillOpacity={1}
|
/>
|
||||||
fill="url(#colorInput)"
|
<Line
|
||||||
strokeWidth={2}
|
yAxisId="tokens"
|
||||||
/>
|
type="monotone"
|
||||||
<Area
|
dataKey="cacheReadTokens"
|
||||||
yAxisId="tokens"
|
name={t("usage.cacheReadTokens", "缓存读取")}
|
||||||
type="monotone"
|
stroke="#a855f7"
|
||||||
dataKey="outputTokens"
|
strokeWidth={2}
|
||||||
name={t("usage.outputTokens", "输出 Tokens")}
|
dot={false}
|
||||||
stroke="#22c55e"
|
isAnimationActive
|
||||||
fillOpacity={1}
|
/>
|
||||||
fill="url(#colorOutput)"
|
<Line
|
||||||
strokeWidth={2}
|
yAxisId="cost"
|
||||||
/>
|
type="monotone"
|
||||||
<Area
|
dataKey="cost"
|
||||||
yAxisId="cost"
|
name={t("usage.cost", "成本")}
|
||||||
type="monotone"
|
stroke="#dc2626"
|
||||||
dataKey="cost"
|
strokeWidth={2}
|
||||||
name={t("usage.cost", "成本")}
|
dot={false}
|
||||||
stroke="#f43f5e"
|
isAnimationActive
|
||||||
fill="none"
|
/>
|
||||||
strokeWidth={2}
|
</LineChart>
|
||||||
strokeDasharray="4 4"
|
</ResponsiveContainer>
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user