diff --git a/.node-version b/.node-version index adb07051..d135defb 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v22.4.1 +22.12.0 \ No newline at end of file diff --git a/README.md b/README.md index 6bc7a1b0..1684228e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # All-in-One Assistant for Claude Code, Codex & Gemini CLI -[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) +[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) @@ -15,7 +15,7 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG ## ❤️Sponsor -![Zhipu GLM](assets/partners/banners/glm-en.jpg) +[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)! @@ -23,19 +23,18 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM - + - - + + - - + +
PackyCodePackyCode Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using this link and enter the "cc-switch" promo code during recharge to get 10% off.
ShanDianShuoThanks 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! Free download for Mac/WinAIGoCodeThanks 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 this link, you'll receive an extra 10% bonus credit on your first top-up!
AIGoCodeThanks 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 this link, you’ll receive an extra 10% bonus credit on your first top-up! -DMXAPIThanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! Register here
@@ -48,7 +47,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM ## Features -### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md) +### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md) **v3.8.0 Major Update (2025-11-28)** diff --git a/README_JA.md b/README_JA.md index 12853b30..aefc6474 100644 --- a/README_JA.md +++ b/README_JA.md @@ -2,7 +2,7 @@ # Claude Code / Codex / Gemini CLI オールインワン・アシスタント -[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) +[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) @@ -15,7 +15,7 @@ ## ❤️スポンサー -![Zhipu GLM](assets/partners/banners/glm-en.jpg) +[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB) 本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります! @@ -23,20 +23,18 @@ - + - - + + - - + +
PackyCodePackyCode PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:このリンクで登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。
ShanDianShuoShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。Mac/Win 版を無料ダウンロードAIGoCode本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
AIGoCode本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! - -DMXAPIDMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!登録はこちら
@@ -49,7 +47,7 @@ ## 特長 -### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md) +### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md) **v3.8.0 メジャーアップデート (2025-11-28)** diff --git a/README_ZH.md b/README_ZH.md index 167257c7..c5c37fc9 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -2,7 +2,7 @@ # Claude Code / Codex / Gemini CLI 全方位辅助工具 -[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases) +[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/) [![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest) @@ -15,7 +15,7 @@ ## ❤️赞助商 -![智谱 GLM](assets/partners/banners/glm-zh.jpg) +[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII) 感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。 @@ -23,20 +23,20 @@ - + - - - - - - + + + + +
PackyCodePackyCode 感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。
ShanDianShuo感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,免费下载
AIGoCodeAIGoCode 感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度!
DMXAPI感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。 +为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!点击这里注册
## 界面预览 @@ -47,7 +47,7 @@ ## 功能特性 -### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md) +### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md) **v3.8.0 重大更新(2025-11-28)** diff --git a/assets/partners/banners/glm-en.jpg b/assets/partners/banners/glm-en.jpg index 479b3e8a..bc580bcd 100644 Binary files a/assets/partners/banners/glm-en.jpg and b/assets/partners/banners/glm-en.jpg differ diff --git a/assets/partners/banners/glm-zh.jpg b/assets/partners/banners/glm-zh.jpg index db318bf2..be1dab61 100644 Binary files a/assets/partners/banners/glm-zh.jpg and b/assets/partners/banners/glm-zh.jpg differ diff --git a/assets/partners/logos/dmx-en.jpg b/assets/partners/logos/dmx-en.jpg new file mode 100644 index 00000000..1587fc26 Binary files /dev/null and b/assets/partners/logos/dmx-en.jpg differ diff --git a/assets/partners/logos/dmx-zh.jpeg b/assets/partners/logos/dmx-zh.jpeg new file mode 100644 index 00000000..7b4f5b74 Binary files /dev/null and b/assets/partners/logos/dmx-zh.jpeg differ diff --git a/components.json b/components.json index 6977d86c..6a4cdb69 100644 --- a/components.json +++ b/components.json @@ -4,7 +4,7 @@ "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.js", + "config": "tailwind.config.cjs", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, diff --git a/package.json b/package.json index 13e355a9..a8777b89 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "cc-switch", "version": "3.9.0-2", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", + "type": "module", "scripts": { "dev": "pnpm tauri dev", "build": "pnpm tauri build", @@ -27,6 +28,7 @@ "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.20", + "code-inspector-plugin": "^1.3.3", "cross-fetch": "^4.1.0", "jsdom": "^25.0.0", "msw": "^2.11.6", @@ -34,7 +36,7 @@ "prettier": "^3.6.2", "tailwindcss": "^3.4.17", "typescript": "^5.3.0", - "vite": "^5.0.0", + "vite": "^7.3.0", "vitest": "^2.0.5" }, "dependencies": { @@ -85,4 +87,4 @@ "zod": "^4.1.12" }, "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10a7ed29..dfe31cb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,10 +167,13 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^4.2.0 - version: 4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1)) + version: 4.7.0(vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1)) autoprefixer: specifier: ^10.4.20 version: 10.4.22(postcss@8.5.6) + code-inspector-plugin: + specifier: ^1.3.3 + version: 1.3.3 cross-fetch: specifier: ^4.1.0 version: 4.1.0 @@ -193,8 +196,8 @@ importers: specifier: ^5.3.0 version: 5.9.2 vite: - specifier: ^5.0.0 - version: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1) + specifier: ^7.3.0 + version: 7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1) vitest: specifier: ^2.0.5 version: 2.1.9(@types/node@20.19.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2)) @@ -261,6 +264,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -274,6 +281,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -302,6 +314,28 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@code-inspector/core@1.3.3': + resolution: {integrity: sha512-1SUCY/XiJ3LuA9TPfS9i7/cUcmdLsgB0chuDcP96ixB2tvYojzgCrglP7CHUGZa1dtWuRLuCiDzkclLetpV4ew==} + + '@code-inspector/esbuild@1.3.3': + resolution: {integrity: sha512-GzX5LQbvh9DXINSUyWymG8Y7u5Tq4oJAnnrCoRiYxQvKBUuu2qVMzpZHIA2iDGxvazgZvr2OK+Sh/We4LutViA==} + + '@code-inspector/mako@1.3.3': + resolution: {integrity: sha512-YPTHwpDtz9zn1vimMcJFCM6ELdBoivY7t2GzgY/iCTfgm6pu1H+oWZiBC35edqYAB7+xE8frspnNsmBhsrA36A==} + + '@code-inspector/turbopack@1.3.3': + resolution: {integrity: sha512-XhqsMtts/Int64LkpO00b4rlg1bw0otlRebX8dSVgZfsujj+Jdv2ngKmQ6RBN3vgj/zV7BfgBLeGgJn7D1kT3A==} + + '@code-inspector/vite@1.3.3': + resolution: {integrity: sha512-phsHVYBsxAhfi6jJ+vpmxuF6jYMuVbozs5e8pkEJL2hQyGVkzP77vfCh1wzmQHcmKUKb2tlrFcvAsRb7oA1W7w==} + + '@code-inspector/webpack@1.3.3': + resolution: {integrity: sha512-qYih7syRXgM45KaWFNNk5Ed4WitVQHCI/2s/DZMFaF1Y2FA9qd1wPGiggNeqdcUsjf9TvVBQw/89gPQZIGwSqQ==} + '@codemirror/autocomplete@6.18.7': resolution: {integrity: sha512-8EzdeIoWPJDsMBwz3zdzwXnUpCzMiCyz5/A3FIPpriaclFCGDkAzK13sMcnsu5rowqiyeQN2Vs2TsOcoDPZirQ==} @@ -397,138 +431,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1133,67 +1323,56 @@ packages: resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.46.2': resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.46.2': resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.46.2': resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.46.2': resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.46.2': resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.46.2': resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.46.2': resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.46.2': resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.46.2': resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.46.2': resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.46.2': resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} @@ -1250,35 +1429,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.8.1': resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.8.1': resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.8.1': resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.8.1': resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.8.1': resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==} @@ -1444,6 +1618,15 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@vue/compiler-core@3.5.26': + resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + + '@vue/compiler-dom@3.5.26': + resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} + + '@vue/shared@3.5.26': + resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1485,6 +1668,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1539,6 +1725,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.1: + resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==} + engines: {node: '>=10'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -1568,6 +1758,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + code-inspector-plugin@1.3.3: + resolution: {integrity: sha512-yDi84v5tgXFSZLLXqHl/Mc2qy9d2CxcYhIaP192NhqTG1zA5uVtiNIzvDAXh5Vaqy8QGYkvBfbG/i55b/sXaSQ==} + codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} @@ -1708,6 +1901,10 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1725,6 +1922,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.0: + resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1752,10 +1953,18 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1851,6 +2060,10 @@ packages: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1966,6 +2179,9 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + launch-ide@1.3.0: + resolution: {integrity: sha512-pxiF+HVNMV0dDc6Z0q89RDmzMF9XmSGaOn4ueTegjMy3cUkezc3zrki5PCiz68zZIqAuhW7iwoWX7JO4Kn6B0A==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -1995,28 +2211,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -2193,6 +2405,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + portfinder@1.0.38: + resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} + engines: {node: '>= 10.12'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -2464,6 +2680,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -2644,6 +2864,46 @@ packages: terser: optional: true + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@2.1.9: resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2843,6 +3103,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.2': @@ -2854,6 +3116,10 @@ snapshots: dependencies: '@babel/types': 7.28.2 + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.0)': dependencies: '@babel/core': 7.28.0 @@ -2889,6 +3155,53 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@code-inspector/core@1.3.3': + dependencies: + '@vue/compiler-dom': 3.5.26 + chalk: 4.1.1 + dotenv: 16.6.1 + launch-ide: 1.3.0 + portfinder: 1.0.38 + transitivePeerDependencies: + - supports-color + + '@code-inspector/esbuild@1.3.3': + dependencies: + '@code-inspector/core': 1.3.3 + transitivePeerDependencies: + - supports-color + + '@code-inspector/mako@1.3.3': + dependencies: + '@code-inspector/core': 1.3.3 + transitivePeerDependencies: + - supports-color + + '@code-inspector/turbopack@1.3.3': + dependencies: + '@code-inspector/core': 1.3.3 + '@code-inspector/webpack': 1.3.3 + transitivePeerDependencies: + - supports-color + + '@code-inspector/vite@1.3.3': + dependencies: + '@code-inspector/core': 1.3.3 + chalk: 4.1.1 + transitivePeerDependencies: + - supports-color + + '@code-inspector/webpack@1.3.3': + dependencies: + '@code-inspector/core': 1.3.3 + transitivePeerDependencies: + - supports-color + '@codemirror/autocomplete@6.18.7': dependencies: '@codemirror/language': 6.11.3 @@ -3035,72 +3348,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.27.2': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.27.2': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.27.2': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.27.2': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.27.2': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.27.2': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.27.2': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.27.2': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.27.2': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.27.2': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.27.2': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.27.2': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.27.2': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.27.2': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.27.2': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.27.2': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.2': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.2': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.2': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.2': + optional: true + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -3914,7 +4305,7 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))': + '@vitejs/plugin-react@4.7.0(vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -3922,7 +4313,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.19(@types/node@20.19.9)(lightningcss@1.30.1) + vite: 7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1) transitivePeerDependencies: - supports-color @@ -3967,6 +4358,21 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@vue/compiler-core@3.5.26': + dependencies: + '@babel/parser': 7.28.5 + '@vue/shared': 3.5.26 + entities: 7.0.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.26': + dependencies: + '@vue/compiler-core': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/shared@3.5.26': {} + agent-base@7.1.4: {} ansi-regex@5.0.1: {} @@ -3998,6 +4404,8 @@ snapshots: assertion-error@2.0.1: {} + async@3.2.6: {} + asynckit@0.4.0: {} autoprefixer@10.4.22(postcss@8.5.6): @@ -4054,6 +4462,11 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.1: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + check-error@2.1.1: {} chokidar@3.6.0: @@ -4094,6 +4507,18 @@ snapshots: - '@types/react' - '@types/react-dom' + code-inspector-plugin@1.3.3: + dependencies: + '@code-inspector/core': 1.3.3 + '@code-inspector/esbuild': 1.3.3 + '@code-inspector/mako': 1.3.3 + '@code-inspector/turbopack': 1.3.3 + '@code-inspector/vite': 1.3.3 + '@code-inspector/webpack': 1.3.3 + chalk: 4.1.1 + transitivePeerDependencies: + - supports-color + codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.18.7 @@ -4209,6 +4634,8 @@ snapshots: dom-accessibility-api@0.6.3: {} + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4223,6 +4650,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4268,8 +4697,39 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escalade@3.2.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -4358,6 +4818,8 @@ snapshots: graphql@16.11.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -4470,6 +4932,11 @@ snapshots: jsonc-parser@3.3.1: {} + launch-ide@1.3.0: + dependencies: + chalk: 4.1.1 + dotenv: 16.6.1 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -4644,6 +5111,13 @@ snapshots: pirates@4.0.7: {} + portfinder@1.0.38: + dependencies: + async: 3.2.6 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + postcss-import@15.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -4903,6 +5377,10 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} symbol-tree@3.2.4: {} @@ -5082,6 +5560,20 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 + vite@7.3.0(@types/node@20.19.9)(jiti@1.21.7)(lightningcss@1.30.1): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.46.2 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.9 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.30.1 + vitest@2.1.9(@types/node@20.19.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2)): dependencies: '@vitest/expect': 2.1.9 diff --git a/postcss.config.js b/postcss.config.cjs similarity index 100% rename from postcss.config.js rename to postcss.config.cjs diff --git a/src-tauri/src/auto_launch.rs b/src-tauri/src/auto_launch.rs index bdb7748a..26c1d5c3 100644 --- a/src-tauri/src/auto_launch.rs +++ b/src-tauri/src/auto_launch.rs @@ -1,16 +1,36 @@ use crate::error::AppError; use auto_launch::{AutoLaunch, AutoLaunchBuilder}; +/// 获取 macOS 上的 .app bundle 路径 +/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app` +#[cfg(target_os = "macos")] +fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option { + let path_str = exe_path.to_string_lossy(); + // 查找 .app/Contents/MacOS/ 模式 + if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") { + let app_bundle_end = app_pos + 4; // ".app" 的结束位置 + Some(std::path::PathBuf::from(&path_str[..app_bundle_end])) + } else { + None + } +} + /// 初始化 AutoLaunch 实例 fn get_auto_launch() -> Result { let app_name = "CC Switch"; - let app_path = + let exe_path = std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?; + // macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端 + #[cfg(target_os = "macos")] + let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path); + + #[cfg(not(target_os = "macos"))] + let app_path = exe_path; + // 使用 AutoLaunchBuilder 消除平台差异 - // Windows/Linux: new() 接受 3 参数 - // macOS: new() 接受 4 参数(含 hidden 参数) - // Builder 模式自动处理这些差异 + // macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径 + // Windows/Linux: 使用注册表/XDG autostart let auto_launch = AutoLaunchBuilder::new() .set_app_name(app_name) .set_app_path(&app_path.to_string_lossy()) @@ -47,3 +67,49 @@ pub fn is_auto_launch_enabled() -> Result { .is_enabled() .map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}"))) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "macos")] + #[test] + fn test_get_macos_app_bundle_path_valid() { + let exe_path = + std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch"); + let result = get_macos_app_bundle_path(exe_path); + assert_eq!( + result, + Some(std::path::PathBuf::from("/Applications/CC Switch.app")) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_get_macos_app_bundle_path_with_spaces() { + let exe_path = + std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch"); + let result = get_macos_app_bundle_path(exe_path); + assert_eq!( + result, + Some(std::path::PathBuf::from("/Users/test/My Apps/CC Switch.app")) + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_get_macos_app_bundle_path_not_in_bundle() { + let exe_path = std::path::Path::new("/usr/local/bin/cc-switch"); + let result = get_macos_app_bundle_path(exe_path); + assert_eq!(result, None); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_get_macos_app_bundle_path_dev_build() { + // 开发环境下的路径通常不在 .app bundle 内 + let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch"); + let result = get_macos_app_bundle_path(exe_path); + assert_eq!(result, None); + } +} diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index 83558514..4c1e71d5 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -56,21 +56,21 @@ pub async fn remove_from_failover_queue( .map_err(|e| e.to_string()) } -/// 获取指定应用的自动故障转移开关状态 +/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取) #[tauri::command] pub async fn get_auto_failover_enabled( state: tauri::State<'_, AppState>, app_type: String, ) -> Result { - let key = format!("auto_failover_enabled_{app_type}"); state .db - .get_setting(&key) - .map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭 + .get_proxy_config_for_app(&app_type) + .await + .map(|config| config.auto_failover_enabled) .map_err(|e| e.to_string()) } -/// 设置指定应用的自动故障转移开关状态 +/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表) /// /// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用 #[tauri::command] @@ -79,12 +79,24 @@ pub async fn set_auto_failover_enabled( app_type: String, enabled: bool, ) -> Result<(), String> { - let key = format!("auto_failover_enabled_{app_type}"); - let value = if enabled { "true" } else { "false" }; - log::info!( - "[Failover] Setting auto_failover_enabled: key='{key}', value='{value}', app_type='{app_type}'" + "[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}" ); - state.db.set_setting(&key, value).map_err(|e| e.to_string()) + // 读取当前配置 + let mut config = state + .db + .get_proxy_config_for_app(&app_type) + .await + .map_err(|e| e.to_string())?; + + // 更新 auto_failover_enabled 字段 + config.auto_failover_enabled = enabled; + + // 写回数据库 + state + .db + .update_proxy_config_for_app(config) + .await + .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index cce5aabd..03157d0d 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -229,3 +229,97 @@ pub fn update_providers_sort_order( let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) } + +// ============================================================================ +// 统一供应商(Universal Provider)命令 +// ============================================================================ + +use crate::provider::UniversalProvider; +use std::collections::HashMap; +use tauri::{AppHandle, Emitter}; + +/// 统一供应商同步完成事件的 payload +#[derive(Clone, serde::Serialize)] +pub struct UniversalProviderSyncedEvent { + /// 操作类型: "upsert" | "delete" | "sync" + pub action: String, + /// 统一供应商 ID + pub id: String, +} + +/// 发送统一供应商同步事件,通知前端刷新供应商列表 +fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { + let _ = app.emit( + "universal-provider-synced", + UniversalProviderSyncedEvent { + action: action.to_string(), + id: id.to_string(), + }, + ); +} + +/// 获取所有统一供应商 +#[tauri::command] +pub fn get_universal_providers( + state: State<'_, AppState>, +) -> Result, String> { + ProviderService::list_universal(state.inner()).map_err(|e| e.to_string()) +} + +/// 获取单个统一供应商 +#[tauri::command] +pub fn get_universal_provider( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string()) +} + +/// 添加或更新统一供应商 +#[tauri::command] +pub fn upsert_universal_provider( + app: AppHandle, + state: State<'_, AppState>, + provider: UniversalProvider, +) -> Result { + let id = provider.id.clone(); + let result = + ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?; + + // 发送事件通知前端刷新 + emit_universal_provider_synced(&app, "upsert", &id); + + Ok(result) +} + +/// 删除统一供应商 +#[tauri::command] +pub fn delete_universal_provider( + app: AppHandle, + state: State<'_, AppState>, + id: String, +) -> Result { + let result = + ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?; + + // 发送事件通知前端刷新 + emit_universal_provider_synced(&app, "delete", &id); + + Ok(result) +} + +/// 同步统一供应商到各应用(手动触发) +#[tauri::command] +pub fn sync_universal_provider( + app: AppHandle, + state: State<'_, AppState>, + id: String, +) -> Result { + let result = + ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?; + + // 发送事件通知前端刷新 + emit_universal_provider_synced(&app, "sync", &id); + + Ok(result) +} diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 27ce31c0..37be402c 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -62,6 +62,63 @@ pub async fn update_proxy_config( state.proxy_service.update_config(&config).await } +// ==================== Global & Per-App Config ==================== + +/// 获取全局代理配置 +/// +/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关) +#[tauri::command] +pub async fn get_global_proxy_config( + state: tauri::State<'_, AppState>, +) -> Result { + let db = &state.db; + db.get_global_proxy_config() + .await + .map_err(|e| e.to_string()) +} + +/// 更新全局代理配置 +/// +/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini) +#[tauri::command] +pub async fn update_global_proxy_config( + state: tauri::State<'_, AppState>, + config: GlobalProxyConfig, +) -> Result<(), String> { + let db = &state.db; + db.update_global_proxy_config(config) + .await + .map_err(|e| e.to_string()) +} + +/// 获取指定应用的代理配置 +/// +/// 返回应用级配置(enabled、auto_failover、超时、熔断器等) +#[tauri::command] +pub async fn get_proxy_config_for_app( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result { + let db = &state.db; + db.get_proxy_config_for_app(&app_type) + .await + .map_err(|e| e.to_string()) +} + +/// 更新指定应用的代理配置 +/// +/// 更新应用级配置(enabled、auto_failover、超时、熔断器等) +#[tauri::command] +pub async fn update_proxy_config_for_app( + state: tauri::State<'_, AppState>, + config: AppProxyConfig, +) -> Result<(), String> { + let db = &state.db; + db.update_proxy_config_for_app(config) + .await + .map_err(|e| e.to_string()) +} + /// 检查代理服务器是否正在运行 #[tauri::command] pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result { @@ -126,19 +183,12 @@ pub async fn reset_circuit_breaker( .reset_provider_circuit_breaker(&provider_id, &app_type) .await?; - // 3. 检查是否应该切回优先级更高的供应商 - let failover_key = format!("auto_failover_enabled_{app_type}"); - let auto_failover_enabled = match db.get_setting(&failover_key) { - Ok(Some(value)) => value == "true", - Ok(None) => { - log::debug!( - "[{app_type}] Failover setting '{failover_key}' not found, defaulting to disabled" - ); - false - } + // 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取) + let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await { + Ok(config) => config.auto_failover_enabled, Err(e) => { log::error!( - "[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled" + "[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" ); false } diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index c759c2a8..81ac6a6c 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -10,6 +10,7 @@ pub mod proxy; pub mod settings; pub mod skills; pub mod stream_check; +pub mod universal_providers; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 导出 FailoverQueueItem 供外部使用 diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index e2573df7..886fbc52 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -8,62 +8,66 @@ use crate::proxy::types::*; use super::super::{lock_conn, Database}; impl Database { - // ==================== Proxy Config ==================== + // ==================== Global Proxy Config ==================== - /// 获取代理配置 - pub async fn get_proxy_config(&self) -> Result { - // 在一个作用域内获取锁并查询,确保锁在await之前释放 + /// 获取全局代理配置(统一字段) + /// + /// 从 claude 行读取(三行镜像一致) + pub async fn get_global_proxy_config(&self) -> Result { + // 使用 block 限制 conn 的作用域,避免跨 await 持有锁 let result = { let conn = lock_conn!(self.conn); conn.query_row( - "SELECT listen_address, listen_port, max_retries, - request_timeout, enable_logging, live_takeover_active - FROM proxy_config WHERE id = 1", + "SELECT proxy_enabled, listen_address, listen_port, enable_logging + FROM proxy_config WHERE app_type = 'claude'", [], |row| { - Ok(ProxyConfig { - listen_address: row.get(0)?, - listen_port: row.get::<_, i32>(1)? as u16, - max_retries: row.get::<_, i32>(2)? as u8, - request_timeout: row.get::<_, i32>(3)? as u64, - enable_logging: row.get::<_, i32>(4)? != 0, - live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0, + Ok(GlobalProxyConfig { + proxy_enabled: row.get::<_, i32>(0)? != 0, + listen_address: row.get(1)?, + listen_port: row.get::<_, i32>(2)? as u16, + enable_logging: row.get::<_, i32>(3)? != 0, }) }, ) - }; // conn锁在这里释放 + }; + // conn 已在 block 结束时释放 match result { Ok(config) => Ok(config), Err(rusqlite::Error::QueryReturnedNoRows) => { - // 如果不存在,插入默认配置 - let default_config = ProxyConfig::default(); - self.update_proxy_config(default_config.clone()).await?; - Ok(default_config) + // 如果不存在,创建默认配置 + self.init_proxy_config_rows().await?; + Ok(GlobalProxyConfig { + proxy_enabled: false, + listen_address: "127.0.0.1".to_string(), + listen_port: 5000, + enable_logging: true, + }) } Err(e) => Err(AppError::Database(e.to_string())), } } - /// 更新代理配置 - pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> { + /// 更新全局代理配置(镜像写三行) + pub async fn update_global_proxy_config( + &self, + config: GlobalProxyConfig, + ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); conn.execute( - "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) - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, - COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), - datetime('now'))", + "UPDATE proxy_config SET + proxy_enabled = ?1, + listen_address = ?2, + listen_port = ?3, + enable_logging = ?4, + updated_at = datetime('now')", rusqlite::params![ - 0, // 已移除自动启用逻辑,保留列但固定为 0 + if config.proxy_enabled { 1 } else { 0 }, config.listen_address, config.listen_port as i32, - config.max_retries as i32, - config.request_timeout as i32, if config.enable_logging { 1 } else { 0 }, - if config.live_takeover_active { 1 } else { 0 }, - "claude", // 兼容旧字段,写入默认值 ], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -71,27 +75,214 @@ impl Database { Ok(()) } - /// 设置 Live 接管状态(仅更新 proxy_config 表,兼容旧逻辑) - /// - /// 注意:此方法不会清除 settings 表中的 proxy_takeover_* 状态。 - /// settings 表的状态由 set_proxy_takeover_enabled 单独管理,用于跨重启保持状态。 - pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> { - // 仅更新 proxy_config 表(兼容旧版本) + /// 获取应用级代理配置 + pub async fn get_proxy_config_for_app( + &self, + app_type: &str, + ) -> Result { + // 使用 block 限制 conn 的作用域,避免跨 await 持有锁 + let app_type_owned = app_type.to_string(); + let result = { + let conn = lock_conn!(self.conn); + conn.query_row( + "SELECT app_type, enabled, auto_failover_enabled, + max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests + FROM proxy_config WHERE app_type = ?1", + [app_type], + |row| { + Ok(AppProxyConfig { + app_type: row.get(0)?, + enabled: row.get::<_, i32>(1)? != 0, + auto_failover_enabled: row.get::<_, i32>(2)? != 0, + max_retries: row.get::<_, i32>(3)? as u32, + streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32, + streaming_idle_timeout: row.get::<_, i32>(5)? as u32, + non_streaming_timeout: row.get::<_, i32>(6)? as u32, + circuit_failure_threshold: row.get::<_, i32>(7)? as u32, + circuit_success_threshold: row.get::<_, i32>(8)? as u32, + circuit_timeout_seconds: row.get::<_, i32>(9)? as u32, + circuit_error_rate_threshold: row.get(10)?, + circuit_min_requests: row.get::<_, i32>(11)? as u32, + }) + }, + ) + }; + // conn 已在 block 结束时释放 + + match result { + Ok(config) => Ok(config), + Err(rusqlite::Error::QueryReturnedNoRows) => { + // 如果不存在,创建默认配置 + self.init_proxy_config_rows().await?; + Ok(AppProxyConfig { + app_type: app_type_owned, + enabled: false, + auto_failover_enabled: false, + max_retries: 3, + streaming_first_byte_timeout: 30, + streaming_idle_timeout: 60, + non_streaming_timeout: 300, + circuit_failure_threshold: 5, + circuit_success_threshold: 2, + circuit_timeout_seconds: 60, + circuit_error_rate_threshold: 0.5, + circuit_min_requests: 10, + }) + } + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 更新应用级代理配置 + pub async fn update_proxy_config_for_app( + &self, + config: AppProxyConfig, + ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); + conn.execute( - "UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1", - rusqlite::params![if active { 1 } else { 0 }], + "UPDATE proxy_config SET + enabled = ?2, + auto_failover_enabled = ?3, + max_retries = ?4, + streaming_first_byte_timeout = ?5, + streaming_idle_timeout = ?6, + non_streaming_timeout = ?7, + circuit_failure_threshold = ?8, + circuit_success_threshold = ?9, + circuit_timeout_seconds = ?10, + circuit_error_rate_threshold = ?11, + circuit_min_requests = ?12, + updated_at = datetime('now') + WHERE app_type = ?1", + rusqlite::params![ + config.app_type, + if config.enabled { 1 } else { 0 }, + if config.auto_failover_enabled { 1 } else { 0 }, + config.max_retries as i32, + config.streaming_first_byte_timeout as i32, + config.streaming_idle_timeout as i32, + config.non_streaming_timeout as i32, + config.circuit_failure_threshold as i32, + config.circuit_success_threshold as i32, + config.circuit_timeout_seconds as i32, + config.circuit_error_rate_threshold, + config.circuit_min_requests as i32, + ], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } + /// 初始化 proxy_config 表的三行数据 + async fn init_proxy_config_rows(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + for app_type in &["claude", "codex", "gemini"] { + conn.execute( + "INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)", + [app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + Ok(()) + } + + // ==================== Legacy Proxy Config (兼容旧代码) ==================== + + /// 获取代理配置(兼容旧接口,返回 claude 行的配置) + pub async fn get_proxy_config(&self) -> Result { + // 使用 block 限制 conn 的作用域,避免跨 await 持有锁 + let result = { + let conn = lock_conn!(self.conn); + conn.query_row( + "SELECT listen_address, listen_port, max_retries, + enable_logging, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout + FROM proxy_config WHERE app_type = 'claude'", + [], + |row| { + Ok(ProxyConfig { + listen_address: row.get(0)?, + listen_port: row.get::<_, i32>(1)? as u16, + max_retries: row.get::<_, i32>(2)? as u8, + request_timeout: 300, // 废弃字段,返回默认值 + enable_logging: row.get::<_, i32>(3)? != 0, + live_takeover_active: false, // 废弃字段 + streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64, + streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64, + non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64, + }) + }, + ) + }; + // conn 已在 block 结束时释放 + + match result { + Ok(config) => Ok(config), + Err(rusqlite::Error::QueryReturnedNoRows) => { + // 如果不存在,初始化默认配置 + self.init_proxy_config_rows().await?; + Ok(ProxyConfig::default()) + } + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 更新代理配置(兼容旧接口,更新所有三行的公共字段) + pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + // 更新所有三行的公共字段 + conn.execute( + "UPDATE proxy_config SET + listen_address = ?1, + listen_port = ?2, + max_retries = ?3, + enable_logging = ?4, + streaming_first_byte_timeout = ?5, + streaming_idle_timeout = ?6, + non_streaming_timeout = ?7, + updated_at = datetime('now')", + rusqlite::params![ + config.listen_address, + config.listen_port as i32, + config.max_retries as i32, + if config.enable_logging { 1 } else { 0 }, + config.streaming_first_byte_timeout as i32, + config.streaming_idle_timeout as i32, + config.non_streaming_timeout as i32, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } + + /// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段) + pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> { + // 不再使用此字段,由 enabled 字段替代 + // 保留空实现以兼容旧代码 + Ok(()) + } + /// 检查是否处于 Live 接管模式 /// - /// v3.8.0+:以 settings 表中的 `proxy_takeover_{app_type}` 为真实来源 + /// 检查是否有任一 app 的 enabled = true pub async fn is_live_takeover_active(&self) -> Result { - self.has_any_proxy_takeover() + let conn = lock_conn!(self.conn); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM proxy_config WHERE enabled = 1", + [], + |row| row.get(0), + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(count > 0) } // ==================== Provider Health ==================== @@ -270,19 +461,22 @@ impl Database { Ok(()) } - // ==================== Circuit Breaker Config ==================== + // ==================== Circuit Breaker Config (Legacy Compatibility) ==================== - /// 获取熔断器配置 + /// 获取熔断器配置(兼容旧接口,从 claude 行读取) + /// + /// 熔断器配置已合并到 proxy_config 表,每 app 独立 + /// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app pub async fn get_circuit_breaker_config( &self, ) -> Result { - 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", + // 使用 block 限制 conn 的作用域,避免跨 await 持有锁 + let result = { + let conn = lock_conn!(self.conn); + conn.query_row( + "SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests + FROM proxy_config WHERE app_type = 'claude'", [], |row| { Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig { @@ -294,27 +488,39 @@ impl Database { }) }, ) - .map_err(|e| AppError::Database(e.to_string()))?; + }; + // conn 已在 block 结束时释放 - Ok(config) + match result { + Ok(config) => Ok(config), + Err(rusqlite::Error::QueryReturnedNoRows) => { + // 如果不存在,初始化默认配置 + self.init_proxy_config_rows().await?; + Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default()) + } + Err(e) => Err(AppError::Database(e.to_string())), + } } - /// 更新熔断器配置 + /// 更新熔断器配置(兼容旧接口,更新所有三行) + /// + /// 熔断器配置已合并到 proxy_config 表 + /// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app pub async fn update_circuit_breaker_config( &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", + "UPDATE proxy_config SET + circuit_failure_threshold = ?1, + circuit_success_threshold = ?2, + circuit_timeout_seconds = ?3, + circuit_error_rate_threshold = ?4, + circuit_min_requests = ?5, + updated_at = datetime('now')", rusqlite::params![ config.failure_threshold as i32, config.success_threshold as i32, diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 68a5c68b..65d2328f 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -63,11 +63,13 @@ impl Database { } } - // --- 代理接管状态管理 --- + // --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)--- /// 获取指定应用的代理接管状态 /// - /// 使用 settings 表存储各应用的接管状态,key 格式: `proxy_takeover_{app_type}` + /// **已废弃**: 请使用 `proxy_config.enabled` 字段替代 + /// 此方法仅用于数据库迁移时读取旧数据 + #[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")] pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result { let key = format!("proxy_takeover_{app_type}"); match self.get_setting(&key)? { @@ -78,8 +80,11 @@ impl Database { /// 设置指定应用的代理接管状态 /// - /// - `true` = 开启代理接管 - /// - `false` = 关闭代理接管 + /// **已废弃**: 请使用 `proxy_config.enabled` 字段替代 + #[deprecated( + since = "3.9.0", + note = "使用 update_proxy_config_for_app() 修改 enabled 字段" + )] pub fn set_proxy_takeover_enabled( &self, app_type: &str, @@ -91,6 +96,9 @@ impl Database { } /// 检查是否有任一应用开启了代理接管 + /// + /// **已废弃**: 请使用 `is_live_takeover_active()` 替代 + #[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")] pub fn has_any_proxy_takeover(&self) -> Result { let conn = lock_conn!(self.conn); let count: i64 = conn @@ -104,6 +112,12 @@ impl Database { } /// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false) + /// + /// **已废弃**: settings 表不再用于存储代理状态 + #[deprecated( + since = "3.9.0", + note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段" + )] pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> { let conn = lock_conn!(self.conn); conn.execute( diff --git a/src-tauri/src/database/dao/universal_providers.rs b/src-tauri/src/database/dao/universal_providers.rs new file mode 100644 index 00000000..32c95500 --- /dev/null +++ b/src-tauri/src/database/dao/universal_providers.rs @@ -0,0 +1,75 @@ +//! 统一供应商 (Universal Provider) DAO +//! +//! 提供统一供应商的 CRUD 操作。 + +use crate::database::{lock_conn, to_json_string, Database}; +use crate::error::AppError; +use crate::provider::UniversalProvider; +use std::collections::HashMap; + +/// 统一供应商的 Settings Key +const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers"; + +impl Database { + /// 获取所有统一供应商 + pub fn get_all_universal_providers(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let mut stmt = conn + .prepare("SELECT value FROM settings WHERE key = ?") + .map_err(|e| AppError::Database(e.to_string()))?; + + let result: Option = stmt + .query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0)) + .ok(); + + match result { + Some(json) => { + serde_json::from_str(&json) + .map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))) + } + None => Ok(HashMap::new()), + } + } + + /// 获取单个统一供应商 + pub fn get_universal_provider(&self, id: &str) -> Result, AppError> { + let providers = self.get_all_universal_providers()?; + Ok(providers.get(id).cloned()) + } + + /// 保存统一供应商(添加或更新) + pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> { + let mut providers = self.get_all_universal_providers()?; + providers.insert(provider.id.clone(), provider.clone()); + self.save_all_universal_providers(&providers) + } + + /// 删除统一供应商 + pub fn delete_universal_provider(&self, id: &str) -> Result { + let mut providers = self.get_all_universal_providers()?; + let existed = providers.remove(id).is_some(); + if existed { + self.save_all_universal_providers(&providers)?; + } + Ok(existed) + } + + /// 保存所有统一供应商(内部方法) + fn save_all_universal_providers( + &self, + providers: &HashMap, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let json = to_json_string(providers)?; + + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + [UNIVERSAL_PROVIDERS_KEY, &json], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } +} + diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 4c160278..0a142911 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -55,47 +55,28 @@ impl Database { // 3. MCP Servers 表 conn.execute( "CREATE TABLE IF NOT EXISTS mcp_servers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - server_config TEXT NOT NULL, - description TEXT, - homepage TEXT, - docs TEXT, - tags TEXT NOT NULL DEFAULT '[]', - enabled_claude BOOLEAN NOT NULL DEFAULT 0, - enabled_codex BOOLEAN NOT NULL DEFAULT 0, - enabled_gemini BOOLEAN NOT NULL DEFAULT 0 - )", + id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, + description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', + enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0 + )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; // 4. Prompts 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS prompts ( - id TEXT NOT NULL, - app_type TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL, - description TEXT, - enabled BOOLEAN NOT NULL DEFAULT 1, - created_at INTEGER, - updated_at INTEGER, - PRIMARY KEY (id, app_type) - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute("CREATE TABLE IF NOT EXISTS prompts ( + id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL, + description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER, + PRIMARY KEY (id, app_type) + )", []).map_err(|e| AppError::Database(e.to_string()))?; // 5. Skills 表 conn.execute( "CREATE TABLE IF NOT EXISTS 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) - )", + 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(e.to_string()))?; @@ -103,169 +84,130 @@ impl Database { // 6. Skill Repos 表 conn.execute( "CREATE TABLE IF NOT EXISTS skill_repos ( - owner TEXT NOT NULL, - name TEXT NOT NULL, - branch TEXT NOT NULL DEFAULT 'main', - enabled BOOLEAN NOT NULL DEFAULT 1, - PRIMARY KEY (owner, name) - )", + owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main', + enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name) + )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - // 7. Settings 表 (通用配置) + // 7. Settings 表 conn.execute( - "CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT - )", + "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - // 8. Proxy Config 表 (代理服务器配置) - // 代理配置表(单例) + // 8. Proxy Config 表(三行结构,app_type 主键) + conn.execute("CREATE TABLE IF NOT EXISTS proxy_config ( + app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')), + proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1', + listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30, + streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300, + circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2, + circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5, + circuit_min_requests INTEGER NOT NULL DEFAULT 10, + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )", []).map_err(|e| AppError::Database(e.to_string()))?; + + // 初始化三行数据(每应用不同默认值) + // + // 兼容旧数据库: + // - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert; + // - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。 + if Self::has_column(conn, "proxy_config", "app_type")? { + conn.execute( + "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests) + VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute( + "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests) + VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute( + "INSERT OR IGNORE INTO proxy_config (app_type, max_retries, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, + circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests) + VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)", + [], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + // 9. Provider Health 表 + conn.execute("CREATE TABLE IF NOT EXISTS provider_health ( + provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1, + consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT, + last_error TEXT, updated_at TEXT NOT NULL, + PRIMARY KEY (provider_id, app_type), + FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE + )", []).map_err(|e| AppError::Database(e.to_string()))?; + + // 10. Proxy Request Logs 表 + conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( + request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER, + duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT, + provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL + )", []).map_err(|e| AppError::Database(e.to_string()))?; + + conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", []) + .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", []) + .map_err(|e| AppError::Database(e.to_string()))?; conn.execute( - "CREATE TABLE IF NOT EXISTS proxy_config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - enabled INTEGER NOT NULL DEFAULT 0, - listen_address TEXT NOT NULL DEFAULT '127.0.0.1', - listen_port INTEGER NOT NULL DEFAULT 5000, - max_retries INTEGER NOT NULL DEFAULT 3, - request_timeout INTEGER NOT NULL DEFAULT 300, - enable_logging INTEGER NOT NULL DEFAULT 1, - target_app TEXT NOT NULL DEFAULT 'claude', - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - )", + "CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - - // 尝试添加 target_app 列(如果表已存在但缺少该列) - // 忽略 "duplicate column name" 错误 - let _ = conn.execute( - "ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'", - [], - ); - - // 9. Provider Health 表 (Provider健康状态) conn.execute( - "CREATE TABLE IF NOT EXISTS provider_health ( - provider_id TEXT NOT NULL, - app_type TEXT NOT NULL, - is_healthy INTEGER NOT NULL DEFAULT 1, - consecutive_failures INTEGER NOT NULL DEFAULT 0, - last_success_at TEXT, - last_failure_at TEXT, - last_error TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY (provider_id, app_type), - FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE - )", + "CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - - // 10. Proxy Request Logs 表 (详细请求日志) conn.execute( - "CREATE TABLE IF NOT EXISTS proxy_request_logs ( - request_id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - app_type TEXT NOT NULL, - model TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_creation_tokens INTEGER NOT NULL DEFAULT 0, - input_cost_usd TEXT NOT NULL DEFAULT '0', - output_cost_usd TEXT NOT NULL DEFAULT '0', - cache_read_cost_usd TEXT NOT NULL DEFAULT '0', - cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', - total_cost_usd TEXT NOT NULL DEFAULT '0', - latency_ms INTEGER NOT NULL, - first_token_ms INTEGER, - duration_ms INTEGER, - status_code INTEGER NOT NULL, - error_message TEXT, - session_id TEXT, - provider_type TEXT, - is_streaming INTEGER NOT NULL DEFAULT 0, - cost_multiplier TEXT NOT NULL DEFAULT '1.0', - created_at INTEGER NOT NULL - )", + "CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_request_logs_provider - ON proxy_request_logs(provider_id, app_type)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_request_logs_created_at - ON proxy_request_logs(created_at)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_request_logs_model - ON proxy_request_logs(model)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_request_logs_session - ON proxy_request_logs(session_id)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_request_logs_status - ON proxy_request_logs(status_code)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 11. Model Pricing 表 (模型定价) + // 11. Model Pricing 表 conn.execute( "CREATE TABLE IF NOT EXISTS model_pricing ( - model_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - input_cost_per_million TEXT NOT NULL, - output_cost_per_million TEXT NOT NULL, - cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', - cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' - )", + model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - // 12. Stream Check Logs 表 (流式健康检查日志) - conn.execute( - "CREATE TABLE IF NOT EXISTS stream_check_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL, - provider_name TEXT NOT NULL, - app_type TEXT NOT NULL, - status TEXT NOT NULL, - success INTEGER NOT NULL, - message TEXT NOT NULL, - response_time_ms INTEGER, - http_status INTEGER, - model_used TEXT, - retry_count INTEGER DEFAULT 0, - tested_at INTEGER NOT NULL - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; + // 12. Stream Check Logs 表 + conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL, + app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL, + response_time_ms INTEGER, http_status INTEGER, model_used TEXT, + retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL + )", []).map_err(|e| AppError::Database(e.to_string()))?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider @@ -274,35 +216,13 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; - // 13. Circuit Breaker Config 表 (熔断器配置) - conn.execute( - "CREATE TABLE IF NOT EXISTS circuit_breaker_config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - failure_threshold INTEGER NOT NULL DEFAULT 5, - success_threshold INTEGER NOT NULL DEFAULT 2, - timeout_seconds INTEGER NOT NULL DEFAULT 60, - error_rate_threshold REAL NOT NULL DEFAULT 0.5, - min_requests INTEGER NOT NULL DEFAULT 10, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - )", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - // 插入默认熔断器配置 - conn.execute( - "INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)", - [], - ) - .map_err(|e| AppError::Database(e.to_string()))?; + // 注意:circuit_breaker_config 已合并到 proxy_config 表中 // 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 - )", + app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL + )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -313,6 +233,20 @@ impl Database { [], ); + // 尝试添加超时配置列到 proxy_config 表 + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30", + [], + ); + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60", + [], + ); + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300", + [], + ); + // 确保 in_failover_queue 列存在(对于已存在的 v2 数据库) Self::add_column_if_missing( conn, @@ -475,6 +409,28 @@ impl Database { "BOOLEAN NOT NULL DEFAULT 0", )?; + // 添加代理超时配置字段 + if Self::table_exists(conn, "proxy_config")? { + Self::add_column_if_missing( + conn, + "proxy_config", + "streaming_first_byte_timeout", + "INTEGER NOT NULL DEFAULT 30", + )?; + Self::add_column_if_missing( + conn, + "proxy_config", + "streaming_idle_timeout", + "INTEGER NOT NULL DEFAULT 60", + )?; + Self::add_column_if_missing( + conn, + "proxy_config", + "non_streaming_timeout", + "INTEGER NOT NULL DEFAULT 300", + )?; + } + // 删除旧的 failover_queue 表(如果存在) conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []) .map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?; @@ -489,35 +445,18 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?; - // proxy_request_logs 表(包含所有字段) - conn.execute( - "CREATE TABLE IF NOT EXISTS proxy_request_logs ( - request_id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - app_type TEXT NOT NULL, - model TEXT NOT NULL, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cache_read_tokens INTEGER NOT NULL DEFAULT 0, - cache_creation_tokens INTEGER NOT NULL DEFAULT 0, - input_cost_usd TEXT NOT NULL DEFAULT '0', - output_cost_usd TEXT NOT NULL DEFAULT '0', - cache_read_cost_usd TEXT NOT NULL DEFAULT '0', - cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', - total_cost_usd TEXT NOT NULL DEFAULT '0', - latency_ms INTEGER NOT NULL, - first_token_ms INTEGER, - duration_ms INTEGER, - status_code INTEGER NOT NULL, - error_message TEXT, - session_id TEXT, - provider_type TEXT, - is_streaming INTEGER NOT NULL DEFAULT 0, - cost_multiplier TEXT NOT NULL DEFAULT '1.0', - created_at INTEGER NOT NULL - )", - [], - )?; + // proxy_request_logs 表 + conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( + request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER, + duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT, + provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL + )", [])?; // 为已存在的表添加新字段 Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?; @@ -539,13 +478,11 @@ impl Database { // model_pricing 表 conn.execute( "CREATE TABLE IF NOT EXISTS model_pricing ( - model_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - input_cost_per_million TEXT NOT NULL, - output_cost_per_million TEXT NOT NULL, - cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', - cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' - )", + model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", [], )?; @@ -557,6 +494,144 @@ impl Database { // 重构 skills 表(添加 app_type 字段) Self::migrate_skills_table(conn)?; + // 重构 proxy_config 为三行结构(每应用独立配置) + Self::migrate_proxy_config_to_per_app(conn)?; + + Ok(()) + } + + /// 将 proxy_config 迁移为三行结构(每应用独立配置) + fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> { + // 检查是否已经是新表结构(幂等性) + if !Self::table_exists(conn, "proxy_config")? { + // 表不存在,跳过迁移(新安装) + return Ok(()); + } + + if Self::has_column(conn, "proxy_config", "app_type")? { + // 已经是三行结构,跳过迁移 + log::info!("proxy_config 已经是三行结构,跳过迁移"); + return Ok(()); + } + + // 读取旧配置 + let old_config = conn + .query_row( + "SELECT listen_address, listen_port, max_retries, enable_logging, + streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout + FROM proxy_config WHERE id = 1", + [], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i32>(1)?, + row.get::<_, i32>(2)?, + row.get::<_, i32>(3)?, + row.get::<_, i32>(4).unwrap_or(30), + row.get::<_, i32>(5).unwrap_or(60), + row.get::<_, i32>(6).unwrap_or(300), + )) + }, + ) + .unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300)); + + let old_cb = conn.query_row( + "SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests + FROM circuit_breaker_config WHERE id = 1", [], + |row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?, + row.get::<_, f64>(3)?, row.get::<_, i32>(4)?)) + ).unwrap_or((5, 2, 60, 0.5, 10)); + + let get_bool = |key: &str| -> bool { + conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| { + r.get::<_, String>(0) + }) + .map(|v| v == "true" || v == "1") + .unwrap_or(false) + }; + + let apps = [ + ( + "claude", + get_bool("proxy_takeover_claude"), + get_bool("auto_failover_enabled_claude"), + 6, + 45, + 90, + 8, + 3, + 90, + 0.6, + 15, + ), + ( + "codex", + get_bool("proxy_takeover_codex"), + get_bool("auto_failover_enabled_codex"), + 3, + old_config.4, + old_config.5, + old_cb.0, + old_cb.1, + old_cb.2, + old_cb.3, + old_cb.4, + ), + ( + "gemini", + get_bool("proxy_takeover_gemini"), + get_bool("auto_failover_enabled_gemini"), + 5, + old_config.4, + old_config.5, + old_cb.0, + old_cb.1, + old_cb.2, + old_cb.3, + old_cb.4, + ), + ]; + + // 创建新表 + conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?; + conn.execute("CREATE TABLE proxy_config_new ( + app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')), + proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1', + listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30, + streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300, + circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2, + circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5, + circuit_min_requests INTEGER NOT NULL DEFAULT 10, + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )", [])?; + + // 插入三行配置 + for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps { + conn.execute( + "INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging, + enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout, + non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests) + VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + rusqlite::params![app, old_config.0, old_config.1, old_config.3, + if takeover { 1 } else { 0 }, if failover { 1 } else { 0 }, + retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m] + ).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?; + } + + // 替换表并清理 + conn.execute("DROP TABLE IF EXISTS proxy_config", [])?; + conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?; + conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?; + conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?; + conn.execute( + "DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'", + [], + )?; + + log::info!("proxy_config 已迁移为三行结构"); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbef7aa9..7c5e2ed1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -597,7 +597,7 @@ pub fn run() { commands::upsert_mcp_server_in_config, commands::delete_mcp_server_in_config, commands::set_mcp_enabled, - // v3.7.0: Unified MCP management + // Unified MCP management commands::get_mcp_servers, commands::upsert_mcp_server, commands::delete_mcp_server, @@ -657,6 +657,11 @@ pub fn run() { commands::get_proxy_status, commands::get_proxy_config, commands::update_proxy_config, + // Global & Per-App Config + commands::get_global_proxy_config, + commands::update_global_proxy_config, + commands::get_proxy_config_for_app, + commands::update_proxy_config_for_app, commands::is_proxy_running, commands::is_live_takeover_active, commands::switch_proxy_provider, @@ -692,6 +697,12 @@ pub fn run() { commands::get_tool_versions, // Provider terminal commands::open_provider_terminal, + // Universal Provider management + commands::get_universal_providers, + commands::get_universal_provider, + commands::upsert_universal_provider, + commands::delete_universal_provider, + commands::sync_universal_provider, ]); let app = builder @@ -848,22 +859,20 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { // 启动时恢复代理状态 // ============================================================ -/// 启动时根据 settings 表中的代理状态自动恢复代理服务 +/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务 /// -/// 检查 `proxy_takeover_claude`、`proxy_takeover_codex`、`proxy_takeover_gemini` 的值, -/// 如果有任一应用的状态为 `true`,则自动启动代理服务并接管对应应用的 Live 配置。 +/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`, +/// 则自动启动代理服务并接管对应应用的 Live 配置。 async fn restore_proxy_state_on_startup(state: &store::AppState) { - // 收集需要恢复接管的应用列表 - let apps_to_restore: Vec<&str> = ["claude", "codex", "gemini"] - .iter() - .filter(|app_type| { - state - .db - .get_proxy_takeover_enabled(app_type) - .unwrap_or(false) - }) - .copied() - .collect(); + // 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取) + let mut apps_to_restore = Vec::new(); + for app_type in ["claude", "codex", "gemini"] { + if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await { + if config.enabled { + apps_to_restore.push(app_type); + } + } + } if apps_to_restore.is_empty() { log::debug!("启动时无需恢复代理状态"); @@ -885,7 +894,11 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) { Err(e) => { log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}"); // 失败时清除该应用的状态,避免下次启动再次尝试 - if let Err(clear_err) = state.db.set_proxy_takeover_enabled(app_type, false) { + if let Err(clear_err) = state + .proxy_service + .set_takeover_for_app(app_type, false) + .await + { log::error!("清除 {app_type} 代理状态失败: {clear_err}"); } } diff --git a/src-tauri/src/mcp/codex.rs b/src-tauri/src/mcp/codex.rs index d86b5679..e7ceacfe 100644 --- a/src-tauri/src/mcp/codex.rs +++ b/src-tauri/src/mcp/codex.rs @@ -359,9 +359,14 @@ pub fn sync_single_server_to_codex( let mut doc = if config_path.exists() { let content = std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - content - .parse::() - .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))? + // 尝试解析现有配置,如果失败则创建新文档(容错处理) + match content.parse::() { + Ok(doc) => doc, + Err(e) => { + log::warn!("解析 Codex config.toml 失败: {e},将创建新配置"); + toml_edit::DocumentMut::new() + } + } } else { toml_edit::DocumentMut::new() }; @@ -409,9 +414,14 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { let content = std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - let mut doc = content - .parse::() - .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?; + // 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容) + let mut doc = match content.parse::() { + Ok(doc) => doc, + Err(e) => { + log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作"); + return Ok(()); + } + }; // 从正确的位置删除:[mcp_servers] if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) { diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 8c082fe6..3a4d1fa1 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -173,3 +173,286 @@ impl ProviderManager { &self.providers } } + +// ============================================================================ +// 统一供应商(Universal Provider)- 跨应用共享配置 +// ============================================================================ + +/// 统一供应商的应用启用状态 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UniversalProviderApps { + #[serde(default)] + pub claude: bool, + #[serde(default)] + pub codex: bool, + #[serde(default)] + pub gemini: bool, +} + +/// Claude 模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ClaudeModelConfig { + /// 主模型 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Haiku 默认模型 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "haikuModel")] + pub haiku_model: Option, + /// Sonnet 默认模型 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sonnetModel")] + pub sonnet_model: Option, + /// Opus 默认模型 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "opusModel")] + pub opus_model: Option, +} + +/// Codex 模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CodexModelConfig { + /// 模型名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// 推理强度 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "reasoningEffort")] + pub reasoning_effort: Option, +} + +/// Gemini 模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GeminiModelConfig { + /// 模型名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// 各应用的模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UniversalProviderModels { + #[serde(skip_serializing_if = "Option::is_none")] + pub claude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub codex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gemini: Option, +} + +/// 统一供应商(跨应用共享配置) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UniversalProvider { + /// 唯一标识 + pub id: String, + /// 供应商名称 + pub name: String, + /// 供应商类型(如 "newapi", "custom") + #[serde(rename = "providerType")] + pub provider_type: String, + /// 应用启用状态 + pub apps: UniversalProviderApps, + /// API 基础地址 + #[serde(rename = "baseUrl")] + pub base_url: String, + /// API 密钥 + #[serde(rename = "apiKey")] + pub api_key: String, + /// 各应用的模型配置 + #[serde(default)] + pub models: UniversalProviderModels, + /// 网站链接 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "websiteUrl")] + pub website_url: Option, + /// 备注信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + /// 图标名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// 图标颜色 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "iconColor")] + pub icon_color: Option, + /// 元数据 + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// 创建时间戳 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "createdAt")] + pub created_at: Option, + /// 排序索引 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sortIndex")] + pub sort_index: Option, +} + +impl UniversalProvider { + /// 创建新的统一供应商 + pub fn new( + id: String, + name: String, + provider_type: String, + base_url: String, + api_key: String, + ) -> Self { + Self { + id, + name, + provider_type, + apps: UniversalProviderApps::default(), + base_url, + api_key, + models: UniversalProviderModels::default(), + website_url: None, + notes: None, + icon: None, + icon_color: None, + meta: None, + created_at: Some(chrono::Utc::now().timestamp_millis()), + sort_index: None, + } + } + + /// 生成 Claude 供应商配置 + pub fn to_claude_provider(&self) -> Option { + if !self.apps.claude { + return None; + } + + let models = self.models.claude.as_ref(); + let model = models + .and_then(|m| m.model.clone()) + .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); + let haiku = models + .and_then(|m| m.haiku_model.clone()) + .unwrap_or_else(|| model.clone()); + let sonnet = models + .and_then(|m| m.sonnet_model.clone()) + .unwrap_or_else(|| model.clone()); + let opus = models + .and_then(|m| m.opus_model.clone()) + .unwrap_or_else(|| model.clone()); + + let settings_config = serde_json::json!({ + "env": { + "ANTHROPIC_BASE_URL": self.base_url, + "ANTHROPIC_AUTH_TOKEN": self.api_key, + "ANTHROPIC_MODEL": model, + "ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku, + "ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet, + "ANTHROPIC_DEFAULT_OPUS_MODEL": opus, + } + }); + + Some(Provider { + id: format!("universal-claude-{}", self.id), + name: self.name.clone(), + settings_config, + website_url: self.website_url.clone(), + category: Some("aggregator".to_string()), + created_at: self.created_at, + sort_index: self.sort_index, + notes: self.notes.clone(), + meta: self.meta.clone(), + icon: self.icon.clone(), + icon_color: self.icon_color.clone(), + in_failover_queue: false, + }) + } + + /// 生成 Codex 供应商配置 + pub fn to_codex_provider(&self) -> Option { + if !self.apps.codex { + return None; + } + + let models = self.models.codex.as_ref(); + let model = models + .and_then(|m| m.model.clone()) + .unwrap_or_else(|| "gpt-4o".to_string()); + let reasoning_effort = models + .and_then(|m| m.reasoning_effort.clone()) + .unwrap_or_else(|| "high".to_string()); + + // 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API) + let codex_base_url = if self.base_url.ends_with("/v1") { + self.base_url.clone() + } else { + format!("{}/v1", self.base_url.trim_end_matches('/')) + }; + + // 生成 Codex 的 config.toml 内容 + let config_toml = format!( + r#"model_provider = "newapi" +model = "{}" +model_reasoning_effort = "{}" +disable_response_storage = true + +[model_providers.newapi] +name = "NewAPI" +base_url = "{}" +wire_api = "responses" +requires_openai_auth = true"#, + model, reasoning_effort, codex_base_url + ); + + let settings_config = serde_json::json!({ + "auth": { + "OPENAI_API_KEY": self.api_key + }, + "config": config_toml + }); + + Some(Provider { + id: format!("universal-codex-{}", self.id), + name: self.name.clone(), + settings_config, + website_url: self.website_url.clone(), + category: Some("aggregator".to_string()), + created_at: self.created_at, + sort_index: self.sort_index, + notes: self.notes.clone(), + meta: self.meta.clone(), + icon: self.icon.clone(), + icon_color: self.icon_color.clone(), + in_failover_queue: false, + }) + } + + /// 生成 Gemini 供应商配置 + pub fn to_gemini_provider(&self) -> Option { + if !self.apps.gemini { + return None; + } + + let models = self.models.gemini.as_ref(); + let model = models + .and_then(|m| m.model.clone()) + .unwrap_or_else(|| "gemini-2.5-pro".to_string()); + + let settings_config = serde_json::json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": self.base_url, + "GEMINI_API_KEY": self.api_key, + "GEMINI_MODEL": model, + } + }); + + Some(Provider { + id: format!("universal-gemini-{}", self.id), + name: self.name.clone(), + settings_config, + website_url: self.website_url.clone(), + category: Some("aggregator".to_string()), + created_at: self.created_at, + sort_index: self.sort_index, + notes: self.notes.clone(), + meta: self.meta.clone(), + icon: self.icon.clone(), + icon_color: self.icon_color.clone(), + in_failover_queue: false, + }) + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index f5b27d48..334182f9 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -39,7 +39,7 @@ pub struct RequestForwarder { failover_manager: Arc, /// AppHandle,用于发射事件和更新托盘 app_handle: Option, - /// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘) + /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) current_provider_id_at_start: String, } @@ -47,17 +47,27 @@ impl RequestForwarder { #[allow(clippy::too_many_arguments)] pub fn new( router: Arc, - timeout_secs: u64, + non_streaming_timeout: u64, max_retries: u8, status: Arc>, current_providers: Arc>>, failover_manager: Arc, app_handle: Option, current_provider_id_at_start: String, + _streaming_first_byte_timeout: u64, + _streaming_idle_timeout: u64, ) -> Self { + // 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作 + // 参考 Claude Code Hub 的 undici 全局超时设计 + const GLOBAL_TIMEOUT_SECS: u64 = 1800; + let mut client_builder = Client::builder(); - if timeout_secs > 0 { - client_builder = client_builder.timeout(Duration::from_secs(timeout_secs)); + if non_streaming_timeout > 0 { + // 使用配置的非流式超时 + client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout)); + } else { + // 禁用超时时使用全局超时作为保底 + client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS)); } let client = client_builder @@ -166,14 +176,24 @@ impl RequestForwarder { let mut last_provider = None; let mut attempted_providers = 0usize; + // 单 Provider 场景下跳过熔断器检查(故障转移关闭时) + let bypass_circuit_breaker = providers.len() == 1; + // 依次尝试每个供应商 for provider in providers.iter() { // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) - let permit = self - .router - .allow_provider_request(&provider.id, app_type_str) - .await; - if !permit.allowed { + // 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求 + let (allowed, used_half_open_permit) = if bypass_circuit_breaker { + (true, false) + } else { + let permit = self + .router + .allow_provider_request(&provider.id, app_type_str) + .await; + (permit.allowed, permit.used_half_open_permit) + }; + + if !allowed { log::debug!( "[{}] Provider {} 熔断器拒绝本次请求,跳过", app_type_str, @@ -182,8 +202,6 @@ impl RequestForwarder { continue; } - let used_half_open_permit = permit.used_half_open_permit; - attempted_providers += 1; log::info!( @@ -412,12 +430,19 @@ impl RequestForwarder { let base_url = adapter.extract_base_url(provider)?; log::info!("[{}] base_url: {}", adapter.name(), base_url); - // 使用适配器构建 URL - let url = adapter.build_url(&base_url, endpoint); - // 检查是否需要格式转换 let needs_transform = adapter.needs_transform(provider); + let effective_endpoint = + if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" { + "/v1/chat/completions" + } else { + endpoint + }; + + // 使用适配器构建 URL + let url = adapter.build_url(&base_url, effective_endpoint); + // 记录原始请求 JSON log::info!( "[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}", @@ -425,10 +450,23 @@ impl RequestForwarder { serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string()) ); + // 应用模型映射(独立于格式转换) + let (mapped_body, _original_model, mapped_model) = + super::model_mapper::apply_model_mapping(body.clone(), provider); + + if let Some(ref mapped) = mapped_model { + log::info!( + "[{}] >>> 模型映射后的请求 JSON:\n{}", + adapter.name(), + serde_json::to_string_pretty(&mapped_body).unwrap_or_default() + ); + log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped); + } + // 转换请求体(如果需要) let request_body = if needs_transform { log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name()); - let transformed = adapter.transform_request(body.clone(), provider)?; + let transformed = adapter.transform_request(mapped_body, provider)?; log::info!( "[{}] >>> 转换后的请求 JSON:\n{}", adapter.name(), @@ -436,7 +474,7 @@ impl RequestForwarder { ); transformed } else { - body.clone() + mapped_body }; log::info!( diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs index 5710f0b4..ca2df078 100644 --- a/src-tauri/src/proxy/handler_config.rs +++ b/src-tauri/src/proxy/handler_config.rs @@ -31,13 +31,26 @@ pub struct UsageParserConfig { // 模型提取器实现 // ============================================================================ -/// Claude 流式响应模型提取(直接使用请求模型) -fn claude_model_extractor(_events: &[Value], request_model: &str) -> String { +/// Claude 流式响应模型提取(优先使用 usage.model) +fn claude_model_extractor(events: &[Value], request_model: &str) -> String { + // 首先尝试从解析的 usage 中获取模型 + if let Some(usage) = TokenUsage::from_claude_stream_events(events) { + if let Some(model) = usage.model { + return model; + } + } request_model.to_string() } -/// OpenAI Chat Completions 流式响应模型提取 +/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model) fn openai_model_extractor(events: &[Value], request_model: &str) -> String { + // 首先尝试从解析的 usage 中获取模型 + if let Some(usage) = TokenUsage::from_openai_stream_events(events) { + if let Some(model) = usage.model { + return model; + } + } + // 回退:从事件中直接提取 events .iter() .find_map(|e| e.get("model")?.as_str()) @@ -45,8 +58,15 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String { .to_string() } -/// Codex Responses API 流式响应模型提取 +/// Codex Responses API 流式响应模型提取(优先使用 usage.model) fn codex_model_extractor(events: &[Value], request_model: &str) -> String { + // 首先尝试从解析的 usage 中获取模型 + if let Some(usage) = TokenUsage::from_codex_stream_events(events) { + if let Some(model) = usage.model { + return model; + } + } + // 回退:从 response.completed 事件中提取 events .iter() .find_map(|e| { diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index a8eae231..86bce211 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -5,23 +5,32 @@ use crate::app_config::AppType; use crate::provider::Provider; use crate::proxy::{ - forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError, + forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError, }; use std::time::Instant; +/// 流式超时配置 +#[derive(Debug, Clone, Copy)] +pub struct StreamingTimeoutConfig { + /// 首字节超时(秒),0 表示禁用 + pub first_byte_timeout: u64, + /// 静默期超时(秒),0 表示禁用 + pub idle_timeout: u64, +} + /// 请求上下文 /// /// 贯穿整个请求生命周期,包含: /// - 计时信息 -/// - 代理配置 +/// - 应用级代理配置(per-app) /// - 选中的 Provider 列表(用于故障转移) /// - 请求模型名称 /// - 日志标签 pub struct RequestContext { /// 请求开始时间 pub start_time: Instant, - /// 代理配置快照 - pub config: ProxyConfig, + /// 应用级代理配置(per-app,包含重试次数和超时配置) + pub app_config: AppProxyConfig, /// 选中的 Provider(故障转移链的第一个) pub provider: Provider, /// 完整的 Provider 列表(用于故障转移) @@ -62,7 +71,14 @@ impl RequestContext { app_type_str: &'static str, ) -> Result { let start_time = Instant::now(); - let config = state.config.read().await.clone(); + + // 从数据库读取应用级代理配置(per-app) + let app_config = state + .db + .get_proxy_config_for_app(app_type_str) + .await + .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; + let current_provider_id = crate::settings::get_current_provider(&app_type).unwrap_or_default(); @@ -96,7 +112,7 @@ impl RequestContext { Ok(Self { start_time, - config, + app_config, provider, providers, current_provider_id, @@ -135,13 +151,15 @@ impl RequestContext { pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { RequestForwarder::new( state.provider_router.clone(), - self.config.request_timeout, - self.config.max_retries, + self.app_config.non_streaming_timeout as u64, + self.app_config.max_retries as u8, state.status.clone(), state.current_providers.clone(), state.failover_manager.clone(), state.app_handle.clone(), self.current_provider_id.clone(), + self.app_config.streaming_first_byte_timeout as u64, + self.app_config.streaming_idle_timeout as u64, ) } @@ -157,4 +175,13 @@ impl RequestContext { pub fn latency_ms(&self) -> u64 { self.start_time.elapsed().as_millis() as u64 } + + /// 获取流式超时配置 + #[inline] + pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig { + StreamingTimeoutConfig { + first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64, + idle_timeout: self.app_config.streaming_idle_timeout as u64, + } + } } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 4df94ea1..20512dc7 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -170,10 +170,14 @@ async fn handle_claude_transform( }) }; + // 获取流式超时配置 + let timeout_config = ctx.streaming_timeout_config(); + let logged_stream = create_logged_passthrough_stream( sse_stream, "Claude/OpenRouter", Some(usage_collector), + timeout_config, ); let mut headers = axum::http::HeaderMap::new(); diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 4f65b853..69063dc0 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -11,6 +11,7 @@ pub mod handler_config; pub mod handler_context; mod handlers; mod health; +pub mod model_mapper; pub mod provider_router; pub mod providers; pub mod response_handler; diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs new file mode 100644 index 00000000..debe155a --- /dev/null +++ b/src-tauri/src/proxy/model_mapper.rs @@ -0,0 +1,264 @@ +//! 模型映射模块 +//! +//! 在请求转发前,根据 Provider 配置替换请求中的模型名称 + +use crate::provider::Provider; +use serde_json::Value; + +/// 模型映射配置 +pub struct ModelMapping { + pub haiku_model: Option, + pub sonnet_model: Option, + pub opus_model: Option, + pub default_model: Option, + pub reasoning_model: Option, +} + +impl ModelMapping { + /// 从 Provider 配置中提取模型映射 + pub fn from_provider(provider: &Provider) -> Self { + let env = provider.settings_config.get("env"); + + Self { + haiku_model: env + .and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + sonnet_model: env + .and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + opus_model: env + .and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + default_model: env + .and_then(|e| e.get("ANTHROPIC_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + reasoning_model: env + .and_then(|e| e.get("ANTHROPIC_REASONING_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), + } + } + + /// 检查是否配置了任何模型映射 + pub fn has_mapping(&self) -> bool { + self.haiku_model.is_some() + || self.sonnet_model.is_some() + || self.opus_model.is_some() + || self.default_model.is_some() + } + + /// 根据原始模型名称获取映射后的模型 + pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String { + let model_lower = original_model.to_lowercase(); + + // 1. thinking 模式优先使用推理模型 + if has_thinking { + if let Some(ref m) = self.reasoning_model { + return m.clone(); + } + } + + // 2. 按模型类型匹配 + if model_lower.contains("haiku") { + if let Some(ref m) = self.haiku_model { + return m.clone(); + } + } + if model_lower.contains("opus") { + if let Some(ref m) = self.opus_model { + return m.clone(); + } + } + if model_lower.contains("sonnet") { + if let Some(ref m) = self.sonnet_model { + return m.clone(); + } + } + + // 3. 默认模型 + if let Some(ref m) = self.default_model { + return m.clone(); + } + + // 4. 无映射,保持原样 + original_model.to_string() + } +} + +/// 检测请求是否启用了 thinking 模式 +pub fn has_thinking_enabled(body: &Value) -> bool { + body.get("thinking") + .and_then(|v| v.as_object()) + .and_then(|o| o.get("type")) + .and_then(|t| t.as_str()) + == Some("enabled") +} + +/// 对请求体应用模型映射 +/// +/// 返回 (映射后的请求体, 原始模型名, 映射后模型名) +pub fn apply_model_mapping( + mut body: Value, + provider: &Provider, +) -> (Value, Option, Option) { + let mapping = ModelMapping::from_provider(provider); + + // 如果没有配置映射,直接返回 + if !mapping.has_mapping() { + let original = body.get("model").and_then(|m| m.as_str()).map(String::from); + return (body, original, None); + } + + // 提取原始模型名 + let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from); + + if let Some(ref original) = original_model { + let has_thinking = has_thinking_enabled(&body); + let mapped = mapping.map_model(original, has_thinking); + + if mapped != *original { + log::info!("[ModelMapper] 模型映射: {original} → {mapped}"); + body["model"] = serde_json::json!(mapped); + return (body, Some(original.clone()), Some(mapped)); + } + } + + (body, original_model, None) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn create_provider_with_mapping() -> Provider { + Provider { + id: "test".to_string(), + name: "Test".to_string(), + settings_config: json!({ + "env": { + "ANTHROPIC_MODEL": "default-model", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped", + "ANTHROPIC_REASONING_MODEL": "reasoning-model" + } + }), + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + fn create_provider_without_mapping() -> Provider { + Provider { + id: "test".to_string(), + name: "Test".to_string(), + settings_config: json!({}), + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + #[test] + fn test_sonnet_mapping() { + let provider = create_provider_with_mapping(); + let body = json!({"model": "claude-sonnet-4-5-20250929"}); + let (result, original, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "sonnet-mapped"); + assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string())); + assert_eq!(mapped, Some("sonnet-mapped".to_string())); + } + + #[test] + fn test_haiku_mapping() { + let provider = create_provider_with_mapping(); + let body = json!({"model": "claude-haiku-4-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "haiku-mapped"); + assert_eq!(mapped, Some("haiku-mapped".to_string())); + } + + #[test] + fn test_opus_mapping() { + let provider = create_provider_with_mapping(); + let body = json!({"model": "claude-opus-4-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "opus-mapped"); + assert_eq!(mapped, Some("opus-mapped".to_string())); + } + + #[test] + fn test_thinking_mode() { + let provider = create_provider_with_mapping(); + let body = json!({ + "model": "claude-sonnet-4-5", + "thinking": {"type": "enabled"} + }); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "reasoning-model"); + assert_eq!(mapped, Some("reasoning-model".to_string())); + } + + #[test] + fn test_thinking_disabled() { + let provider = create_provider_with_mapping(); + let body = json!({ + "model": "claude-sonnet-4-5", + "thinking": {"type": "disabled"} + }); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "sonnet-mapped"); + assert_eq!(mapped, Some("sonnet-mapped".to_string())); + } + + #[test] + fn test_unknown_model_uses_default() { + let provider = create_provider_with_mapping(); + let body = json!({"model": "some-unknown-model"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "default-model"); + assert_eq!(mapped, Some("default-model".to_string())); + } + + #[test] + fn test_no_mapping_configured() { + let provider = create_provider_without_mapping(); + let body = json!({"model": "claude-sonnet-4-5"}); + let (result, original, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "claude-sonnet-4-5"); + assert_eq!(original, Some("claude-sonnet-4-5".to_string())); + assert!(mapped.is_none()); + } + + #[test] + fn test_case_insensitive() { + let provider = create_provider_with_mapping(); + let body = json!({"model": "Claude-SONNET-4-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "sonnet-mapped"); + assert_eq!(mapped, Some("sonnet-mapped".to_string())); + } +} diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 04eead5f..1cbae1a6 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -35,25 +35,16 @@ impl ProviderRouter { pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { let mut result = Vec::new(); - // 检查该应用的自动故障转移开关是否开启 - let failover_key = format!("auto_failover_enabled_{app_type}"); - let auto_failover_enabled = match self.db.get_setting(&failover_key) { - Ok(Some(value)) => { - let enabled = value == "true"; - log::info!( - "[{app_type}] Failover setting '{failover_key}' = '{value}', enabled: {enabled}" - ); + // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) + let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { + Ok(config) => { + let enabled = config.auto_failover_enabled; + log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}"); enabled } - Ok(None) => { - log::warn!( - "[{app_type}] Failover setting '{failover_key}' not found in database, defaulting to disabled" - ); - false - } Err(e) => { log::error!( - "[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled" + "[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" ); false } @@ -91,29 +82,19 @@ impl ProviderRouter { } } } else { - // 故障转移关闭:仅使用当前供应商 - log::info!("[{app_type}] Failover disabled, using current provider only"); + // 故障转移关闭:仅使用当前供应商,跳过熔断器检查 + // 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差 + log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)"); if let Some(current_id) = self.db.get_current_provider(app_type)? { if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? { - 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); - } else { - log::warn!( - "[{}] Current provider {} circuit breaker open", - app_type, - current.name - ); - } + log::info!( + "[{}] Current provider: {} ({})", + app_type, + current.name, + current.id + ); + result.push(current); } } } @@ -156,9 +137,16 @@ impl ProviderRouter { success: bool, error_msg: Option, ) -> 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); + // 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用) + let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await { + Ok(app_config) => app_config.circuit_failure_threshold, + Err(e) => { + log::warn!( + "Failed to load circuit config for {app_type}, using default threshold: {e}" + ); + 5 // 默认值 + } + }; // 2. 更新熔断器状态 let circuit_key = format!("{app_type}:{provider_id}"); @@ -255,12 +243,34 @@ impl ProviderRouter { return breaker.clone(); } - // 从数据库加载配置 - let config = self - .db - .get_circuit_breaker_config() - .await - .unwrap_or_default(); + // 从 key 中提取 app_type (格式: "app_type:provider_id") + let app_type = key.split(':').next().unwrap_or("claude"); + + // 按应用独立读取熔断器配置 + let config = match self.db.get_proxy_config_for_app(app_type).await { + Ok(app_config) => { + log::debug!( + "Loading circuit breaker config for {key} (app={app_type}): \ + failure_threshold={}, success_threshold={}, timeout={}s", + app_config.circuit_failure_threshold, + app_config.circuit_success_threshold, + app_config.circuit_timeout_seconds + ); + crate::proxy::circuit_breaker::CircuitBreakerConfig { + failure_threshold: app_config.circuit_failure_threshold, + success_threshold: app_config.circuit_success_threshold, + timeout_seconds: app_config.circuit_timeout_seconds as u64, + error_rate_threshold: app_config.circuit_error_rate_threshold, + min_requests: app_config.circuit_min_requests, + } + } + Err(e) => { + log::warn!( + "Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default" + ); + crate::proxy::circuit_breaker::CircuitBreakerConfig::default() + } + }; log::debug!("Creating new circuit breaker for {key} with config: {config:?}"); @@ -325,8 +335,11 @@ mod tests { db.add_to_failover_queue("claude", "b").unwrap(); db.add_to_failover_queue("claude", "a").unwrap(); - db.set_setting("auto_failover_enabled_claude", "true") - .unwrap(); + + // 启用自动故障转移(使用新的 proxy_config API) + let mut config = db.get_proxy_config_for_app("claude").await.unwrap(); + config.auto_failover_enabled = true; + db.update_proxy_config_for_app(config).await.unwrap(); let router = ProviderRouter::new(db.clone()); let providers = router.select_providers("claude").await.unwrap(); @@ -359,8 +372,11 @@ mod tests { db.add_to_failover_queue("claude", "a").unwrap(); db.add_to_failover_queue("claude", "b").unwrap(); - db.set_setting("auto_failover_enabled_claude", "true") - .unwrap(); + + // 启用自动故障转移(使用新的 proxy_config API) + let mut config = db.get_proxy_config_for_app("claude").await.unwrap(); + config.auto_failover_enabled = true; + db.update_proxy_config_for_app(config).await.unwrap(); let router = ProviderRouter::new(db.clone()); diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index ba625425..a0dfd87e 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -48,6 +48,24 @@ impl ClaudeAdapter { false } + /// 检测 OpenRouter 是否启用兼容模式 + fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool { + if !self.is_openrouter(provider) { + return false; + } + + let raw = provider.settings_config.get("openrouter_compat_mode"); + match raw { + Some(serde_json::Value::Bool(enabled)) => *enabled, + Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0, + Some(serde_json::Value::String(value)) => { + let normalized = value.trim().to_lowercase(); + normalized == "true" || normalized == "1" + } + _ => true, + } + } + /// 检测是否为仅 Bearer 认证模式 fn is_bearer_only_mode(&self, provider: &Provider) -> bool { // 检查 settings_config 中的 auth_mode @@ -197,11 +215,7 @@ impl ProviderAdapter for ClaudeAdapter { // 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 // // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 - // 如需回退旧逻辑,可恢复下面这段分支: - // - // if base_url.contains("openrouter.ai") { - // return format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); - // } + // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 format!( "{}/{}", @@ -235,8 +249,7 @@ impl ProviderAdapter for ClaudeAdapter { // Anthropic ↔ OpenAI 的格式转换。 // // 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行: - // self.is_openrouter(_provider) - false + self.is_openrouter_compat_enabled(_provider) } fn transform_request( @@ -430,6 +443,14 @@ mod tests { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" } })); - assert!(!adapter.needs_transform(&openrouter_provider)); + assert!(adapter.needs_transform(&openrouter_provider)); + + let openrouter_disabled = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" + }, + "openrouter_compat_mode": false + })); + assert!(!adapter.needs_transform(&openrouter_disabled)); } } diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 38b59fae..714302b3 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -3,8 +3,11 @@ //! 统一处理流式和非流式 API 响应 use super::{ - handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState, - usage::parser::TokenUsage, ProxyError, + handler_config::UsageParserConfig, + handler_context::{RequestContext, StreamingTimeoutConfig}, + server::ProxyState, + usage::parser::TokenUsage, + ProxyError, }; use axum::response::Response; use bytes::Bytes; @@ -17,6 +20,7 @@ use std::{ atomic::{AtomicBool, Ordering}, Arc, }, + time::Duration, }; use tokio::sync::Mutex; @@ -60,8 +64,12 @@ pub async fn handle_streaming( // 创建使用量收集器 let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config); - // 创建带日志的透传流 - let logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector)); + // 获取流式超时配置 + let timeout_config = ctx.streaming_timeout_config(); + + // 创建带日志和超时的透传流 + let logged_stream = + create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config); let body = axum::body::Body::from_stream(logged_stream); builder.body(body).unwrap() @@ -93,12 +101,16 @@ pub async fn handle_non_streaming( // 解析使用量 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); + // 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型 + let model = if let Some(ref m) = usage.model { + m.clone() + } else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) { + m.to_string() + } else { + ctx.request_model.clone() + }; - spawn_log_usage(state, ctx, usage, model, status.as_u16(), false); + spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false); } else { log::debug!( "[{}] 未能解析 usage 信息,跳过记录", @@ -344,21 +356,60 @@ async fn log_usage_internal( } } -/// 创建带日志记录的透传流 +/// 创建带日志记录和超时控制的透传流 pub fn create_logged_passthrough_stream( stream: impl Stream> + Send + 'static, tag: &'static str, usage_collector: Option, + timeout_config: StreamingTimeoutConfig, ) -> impl Stream> + Send { async_stream::stream! { let mut buffer = String::new(); let mut collector = usage_collector; + let mut is_first_chunk = true; + + // 超时配置 + let first_byte_timeout = if timeout_config.first_byte_timeout > 0 { + Some(Duration::from_secs(timeout_config.first_byte_timeout)) + } else { + None + }; + let idle_timeout = if timeout_config.idle_timeout > 0 { + Some(Duration::from_secs(timeout_config.idle_timeout)) + } else { + None + }; tokio::pin!(stream); - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { + loop { + // 选择超时时间:首字节超时或静默期超时 + let timeout_duration = if is_first_chunk { + first_byte_timeout + } else { + idle_timeout + }; + + let chunk_result = match timeout_duration { + Some(duration) => { + match tokio::time::timeout(duration, stream.next()).await { + Ok(Some(chunk)) => Some(chunk), + Ok(None) => None, // 流结束 + Err(_) => { + // 超时 + let timeout_type = if is_first_chunk { "首字节" } else { "静默期" }; + log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs()); + yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时"))); + break; + } + } + } + None => stream.next().await, // 无超时限制 + }; + + match chunk_result { + Some(Ok(bytes)) => { + is_first_chunk = false; let text = String::from_utf8_lossy(&bytes); buffer.push_str(&text); @@ -394,11 +445,15 @@ pub fn create_logged_passthrough_stream( yield Ok(bytes); } - Err(e) => { + Some(Err(e)) => { log::error!("[{tag}] 流错误: {e}"); yield Err(std::io::Error::other(e.to_string())); break; } + None => { + // 流正常结束 + break; + } } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index f00af8de..9ce10347 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -9,13 +9,34 @@ pub struct ProxyConfig { pub listen_port: u16, /// 最大重试次数 pub max_retries: u8, - /// 请求超时时间(秒) + /// 请求超时时间(秒)- 已废弃,保留兼容 pub request_timeout: u64, /// 是否启用日志 pub enable_logging: bool, /// 是否正在接管 Live 配置 #[serde(default)] pub live_takeover_active: bool, + /// 流式首字超时(秒)- 等待首个数据块的最大时间 + #[serde(default = "default_streaming_first_byte_timeout")] + pub streaming_first_byte_timeout: u64, + /// 流式静默超时(秒)- 两个数据块之间的最大间隔 + #[serde(default = "default_streaming_idle_timeout")] + pub streaming_idle_timeout: u64, + /// 非流式总超时(秒)- 非流式请求的总超时时间 + #[serde(default = "default_non_streaming_timeout")] + pub non_streaming_timeout: u64, +} + +fn default_streaming_first_byte_timeout() -> u64 { + 30 +} + +fn default_streaming_idle_timeout() -> u64 { + 60 +} + +fn default_non_streaming_timeout() -> u64 { + 600 } impl Default for ProxyConfig { @@ -27,6 +48,9 @@ impl Default for ProxyConfig { request_timeout: 300, enable_logging: true, live_takeover_active: false, + streaming_first_byte_timeout: 30, + streaming_idle_timeout: 60, + non_streaming_timeout: 600, } } } @@ -123,3 +147,47 @@ pub struct LiveBackup { /// 备份时间 pub backed_up_at: String, } + +/// 全局代理配置(统一字段,三行镜像) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlobalProxyConfig { + /// 代理总开关 + pub proxy_enabled: bool, + /// 监听地址 + pub listen_address: String, + /// 监听端口 + pub listen_port: u16, + /// 是否启用日志 + pub enable_logging: bool, +} + +/// 应用级代理配置(每个 app 独立) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppProxyConfig { + /// 应用类型 (claude/codex/gemini) + pub app_type: String, + /// 该 app 代理启用开关 + pub enabled: bool, + /// 该 app 自动故障转移开关 + pub auto_failover_enabled: bool, + /// 最大重试次数 + pub max_retries: u32, + /// 流式首字超时(秒) + pub streaming_first_byte_timeout: u32, + /// 流式静默超时(秒) + pub streaming_idle_timeout: u32, + /// 非流式总超时(秒) + pub non_streaming_timeout: u32, + /// 熔断失败阈值 + pub circuit_failure_threshold: u32, + /// 熔断恢复阈值 + pub circuit_success_threshold: u32, + /// 熔断恢复等待时间(秒) + pub circuit_timeout_seconds: u32, + /// 错误率阈值 + pub circuit_error_rate_threshold: f64, + /// 计算错误率的最小请求数 + pub circuit_min_requests: u32, +} diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 950d6f66..33c1aaf1 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -34,6 +34,12 @@ impl TokenUsage { /// 从 Claude API 非流式响应解析 pub fn from_claude_response(body: &Value) -> Option { let usage = body.get("usage")?; + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: usage.get("input_tokens")?.as_u64()? as u32, output_tokens: usage.get("output_tokens")?.as_u64()? as u32, @@ -45,7 +51,7 @@ impl TokenUsage { .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, - model: None, + model, }) } @@ -53,11 +59,20 @@ impl TokenUsage { #[allow(dead_code)] pub fn from_claude_stream_events(events: &[Value]) -> Option { let mut usage = Self::default(); + let mut model: Option = None; for event in events { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { match event_type { "message_start" => { + // 从 message_start 提取模型名称 + if model.is_none() { + if let Some(message) = event.get("message") { + if let Some(m) = message.get("model").and_then(|v| v.as_str()) { + model = Some(m.to_string()); + } + } + } if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) { // 从 message_start 获取 input_tokens(原生 Claude API) if let Some(input) = @@ -102,6 +117,7 @@ impl TokenUsage { } if usage.input_tokens > 0 || usage.output_tokens > 0 { + usage.model = model; Some(usage) } else { None @@ -141,6 +157,12 @@ impl TokenUsage { return None; } + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: input_tokens? as u32, output_tokens: output_tokens? as u32, @@ -152,7 +174,7 @@ impl TokenUsage { .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, - model: None, + model, }) } @@ -222,12 +244,18 @@ impl TokenUsage { .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: prompt_tokens as u32, output_tokens: completion_tokens as u32, cache_read_tokens: cached_tokens, cache_creation_tokens: 0, - model: None, + model, }) } @@ -322,6 +350,7 @@ mod tests { #[test] fn test_claude_response_parsing() { let response = json!({ + "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 100, "output_tokens": 50, @@ -335,10 +364,60 @@ mod tests { assert_eq!(usage.output_tokens, 50); assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_creation_tokens, 10); + assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); + } + + #[test] + fn test_claude_response_parsing_no_model() { + let response = json!({ + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 10 + } + }); + + let usage = TokenUsage::from_claude_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 10); + assert_eq!(usage.model, None); } #[test] fn test_claude_stream_parsing() { + let events = vec![ + json!({ + "type": "message_start", + "message": { + "model": "claude-sonnet-4-20250514", + "usage": { + "input_tokens": 100, + "cache_read_input_tokens": 20, + "cache_creation_input_tokens": 10 + } + } + }), + json!({ + "type": "message_delta", + "usage": { + "output_tokens": 50 + } + }), + ]; + + let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.cache_read_tokens, 20); + assert_eq!(usage.cache_creation_tokens, 10); + assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); + } + + #[test] + fn test_claude_stream_parsing_no_model() { let events = vec![ json!({ "type": "message_start", @@ -363,6 +442,7 @@ mod tests { assert_eq!(usage.output_tokens, 50); assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_creation_tokens, 10); + assert_eq!(usage.model, None); } #[test] @@ -481,6 +561,7 @@ mod tests { json!({ "type": "message_start", "message": { + "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 0, "output_tokens": 0 @@ -502,6 +583,7 @@ mod tests { let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); assert_eq!(usage.input_tokens, 150); assert_eq!(usage.output_tokens, 75); + assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); } #[test] @@ -512,6 +594,7 @@ mod tests { json!({ "type": "message_start", "message": { + "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 200, "cache_read_input_tokens": 50 @@ -530,5 +613,6 @@ mod tests { assert_eq!(usage.input_tokens, 200); assert_eq!(usage.output_tokens, 100); assert_eq!(usage.cache_read_tokens, 50); + assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 3835fb7a..8a46a2b2 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -146,7 +146,9 @@ impl ConfigService { let cfg_text = settings.get("config").and_then(Value::as_str); crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; - crate::mcp::sync_enabled_to_codex(config)?; + // 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用 + // sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空 + // MCP 的启用/禁用应通过 McpService::toggle_app 进行 let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; if let Some(manager) = config.get_manager_mut(&AppType::Codex) { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7790ca41..59957441 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -690,3 +690,140 @@ pub struct ProviderSortUpdate { #[serde(rename = "sortIndex")] pub sort_index: usize, } + +// ============================================================================ +// 统一供应商(Universal Provider)服务方法 +// ============================================================================ + +use crate::provider::UniversalProvider; +use std::collections::HashMap; + +impl ProviderService { + /// 获取所有统一供应商 + pub fn list_universal( + state: &AppState, + ) -> Result, AppError> { + state.db.get_all_universal_providers() + } + + /// 获取单个统一供应商 + pub fn get_universal( + state: &AppState, + id: &str, + ) -> Result, AppError> { + state.db.get_universal_provider(id) + } + + /// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps) + pub fn upsert_universal( + state: &AppState, + provider: UniversalProvider, + ) -> Result { + // 保存统一供应商 + state.db.save_universal_provider(&provider)?; + + Ok(true) + } + + /// 删除统一供应商 + pub fn delete_universal(state: &AppState, id: &str) -> Result { + // 获取统一供应商(用于删除生成的子供应商) + let provider = state.db.get_universal_provider(id)?; + + // 删除统一供应商 + state.db.delete_universal_provider(id)?; + + // 删除生成的子供应商 + if let Some(p) = provider { + if p.apps.claude { + let claude_id = format!("universal-claude-{}", id); + let _ = state.db.delete_provider("claude", &claude_id); + } + if p.apps.codex { + let codex_id = format!("universal-codex-{}", id); + let _ = state.db.delete_provider("codex", &codex_id); + } + if p.apps.gemini { + let gemini_id = format!("universal-gemini-{}", id); + let _ = state.db.delete_provider("gemini", &gemini_id); + } + } + + Ok(true) + } + + /// 同步统一供应商到各应用 + pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result { + let provider = state + .db + .get_universal_provider(id)? + .ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?; + + // 同步到 Claude + if let Some(mut claude_provider) = provider.to_claude_provider() { + // 合并已有配置 + if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? { + let mut merged = existing.settings_config.clone(); + Self::merge_json(&mut merged, &claude_provider.settings_config); + claude_provider.settings_config = merged; + } + state.db.save_provider("claude", &claude_provider)?; + } else { + // 如果禁用了 Claude,删除对应的子供应商 + let claude_id = format!("universal-claude-{}", id); + let _ = state.db.delete_provider("claude", &claude_id); + } + + // 同步到 Codex + if let Some(mut codex_provider) = provider.to_codex_provider() { + // 合并已有配置 + if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? { + let mut merged = existing.settings_config.clone(); + Self::merge_json(&mut merged, &codex_provider.settings_config); + codex_provider.settings_config = merged; + } + state.db.save_provider("codex", &codex_provider)?; + } else { + let codex_id = format!("universal-codex-{}", id); + let _ = state.db.delete_provider("codex", &codex_id); + } + + // 同步到 Gemini + if let Some(mut gemini_provider) = provider.to_gemini_provider() { + // 合并已有配置 + if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? { + let mut merged = existing.settings_config.clone(); + Self::merge_json(&mut merged, &gemini_provider.settings_config); + gemini_provider.settings_config = merged; + } + state.db.save_provider("gemini", &gemini_provider)?; + } else { + let gemini_id = format!("universal-gemini-{}", id); + let _ = state.db.delete_provider("gemini", &gemini_id); + } + + Ok(true) + } + + /// 递归合并 JSON:base 为底,patch 覆盖同名字段 + fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) { + use serde_json::Value; + + match (base, patch) { + (Value::Object(base_map), Value::Object(patch_map)) => { + for (k, v_patch) in patch_map { + match base_map.get_mut(k) { + Some(v_base) => Self::merge_json(v_base, v_patch), + None => { + base_map.insert(k.clone(), v_patch.clone()); + } + } + } + } + // 其它类型:直接覆盖 + (base_val, patch_val) => { + *base_val = patch_val.clone(); + } + } + } +} diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 03570eb0..29b5b315 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -43,7 +43,22 @@ impl ProxyService { /// 启动代理服务器 pub async fn start(&self) -> Result { - // 1. 获取配置 + // 1. 启动时自动设置 proxy_enabled = true + let mut global_config = self + .db + .get_global_proxy_config() + .await + .map_err(|e| format!("获取全局代理配置失败: {e}"))?; + + if !global_config.proxy_enabled { + global_config.proxy_enabled = true; + self.db + .update_global_proxy_config(global_config.clone()) + .await + .map_err(|e| format!("更新代理总开关失败: {e}"))?; + } + + // 2. 获取配置 let config = self .db .get_proxy_config() @@ -115,14 +130,7 @@ impl ProxyService { return Err(e); } - // 5. 设置 settings 表中所有应用的接管状态(用于重启后自动恢复) - for app in ["claude", "codex", "gemini"] { - if let Err(e) = self.db.set_proxy_takeover_enabled(app, true) { - log::warn!("设置 {app} 接管状态失败: {e}"); - } - } - - // 6. 启动代理服务器 + // 5. 启动代理服务器 match self.start().await { Ok(info) => Ok(info), Err(e) => { @@ -132,8 +140,6 @@ impl ProxyService { Ok(()) => { let _ = self.db.set_live_takeover_active(false).await; let _ = self.db.delete_all_live_backups().await; - // 清除 settings 状态 - let _ = self.db.clear_all_proxy_takeover(); } Err(restore_err) => { log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); @@ -146,29 +152,30 @@ impl ProxyService { /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理) pub async fn get_takeover_status(&self) -> Result { - let claude = self + // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测 + let claude_enabled = self .db - .get_live_backup("claude") + .get_proxy_config_for_app("claude") .await - .map_err(|e| format!("获取 Claude 接管状态失败: {e}"))? - .is_some(); - let codex = self + .map(|c| c.enabled) + .unwrap_or(false); + let codex_enabled = self .db - .get_live_backup("codex") + .get_proxy_config_for_app("codex") .await - .map_err(|e| format!("获取 Codex 接管状态失败: {e}"))? - .is_some(); - let gemini = self + .map(|c| c.enabled) + .unwrap_or(false); + let gemini_enabled = self .db - .get_live_backup("gemini") + .get_proxy_config_for_app("gemini") .await - .map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))? - .is_some(); + .map(|c| c.enabled) + .unwrap_or(false); Ok(ProxyTakeoverStatus { - claude, - codex, - gemini, + claude: claude_enabled, + codex: codex_enabled, + gemini: gemini_enabled, }) } @@ -187,13 +194,13 @@ impl ProxyService { } // 2) 已接管则直接返回(幂等) - if self + let current_config = self .db - .get_live_backup(app_type_str) + .get_proxy_config_for_app(app_type_str) .await - .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))? - .is_some() - { + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + + if current_config.enabled { return Ok(()); } @@ -223,25 +230,32 @@ impl ProxyService { return Err(e); } - // 6) 设置 settings 表中的接管状态 + // 6) 设置 proxy_config.enabled = true + let mut updated_config = self + .db + .get_proxy_config_for_app(app_type_str) + .await + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + updated_config.enabled = true; self.db - .set_proxy_takeover_enabled(app_type_str, true) - .map_err(|e| format!("设置 {app_type_str} 接管状态失败: {e}"))?; + .update_proxy_config_for_app(updated_config) + .await + .map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}"))?; // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) let _ = self.db.set_live_takeover_active(true).await; return Ok(()); } - // 关闭接管:无备份则视为未接管(幂等) - let has_backup = self + // 关闭接管:检查 enabled 状态 + let current_config = self .db - .get_live_backup(app_type_str) + .get_proxy_config_for_app(app_type_str) .await - .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))? - .is_some(); - if !has_backup { - return Ok(()); + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + + if !current_config.enabled { + return Ok(()); // 未接管,幂等返回 } // 1) 恢复 Live 配置 @@ -253,10 +267,17 @@ impl ProxyService { .await .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; - // 3) 清除 settings 表中该应用的接管状态 + // 3) 设置 proxy_config.enabled = false + let mut updated_config = self + .db + .get_proxy_config_for_app(app_type_str) + .await + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + updated_config.enabled = false; self.db - .set_proxy_takeover_enabled(app_type_str, false) - .map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?; + .update_proxy_config_for_app(updated_config) + .await + .map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?; // 4) 清除该应用的健康状态(关闭代理时重置队列状态) self.db @@ -265,12 +286,14 @@ impl ProxyService { .map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?; // 5) 若无其它接管,更新旧标志,并停止代理服务 - let has_any_backup = self + // 检查是否还有其它 app 的 enabled = true + let any_enabled = self .db - .has_any_live_backup() + .is_live_takeover_active() .await - .map_err(|e| format!("检查 Live 备份失败: {e}"))?; - if !has_any_backup { + .map_err(|e| format!("检查接管状态失败: {e}"))?; + + if !any_enabled { let _ = self.db.set_live_takeover_active(false).await; if self.is_running().await { @@ -502,6 +525,20 @@ impl ProxyService { .await .map_err(|e| format!("停止代理服务器失败: {e}"))?; + // 停止时设置 proxy_enabled = false + let mut global_config = self + .db + .get_global_proxy_config() + .await + .map_err(|e| format!("获取全局代理配置失败: {e}"))?; + + if global_config.proxy_enabled { + global_config.proxy_enabled = false; + if let Err(e) = self.db.update_global_proxy_config(global_config).await { + log::warn!("更新代理总开关失败: {e}"); + } + } + log::info!("代理服务器已停止"); Ok(()) } else { @@ -527,10 +564,17 @@ impl ProxyService { .await .map_err(|e| format!("清除接管状态失败: {e}"))?; - // 4. 清除 settings 表中的代理状态(用户手动关闭,不需要下次自动恢复) - self.db - .clear_all_proxy_takeover() - .map_err(|e| format!("清除代理状态失败: {e}"))?; + // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复) + for app_type in ["claude", "codex", "gemini"] { + if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await { + if config.enabled { + config.enabled = false; + if let Err(e) = self.db.update_proxy_config_for_app(config).await { + log::warn!("清除 {app_type} enabled 状态失败: {e}"); + } + } + } + } // 5. 删除备份 self.db @@ -562,7 +606,7 @@ impl ProxyService { self.restore_live_configs().await?; // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版) - // 注意:仅更新 proxy_config 表,不清除 settings 表中的 proxy_takeover_* 状态 + // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复 if let Ok(mut config) = self.db.get_proxy_config().await { config.live_takeover_active = false; let _ = self.db.update_proxy_config(config).await; diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 36004344..d8a5db6f 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -3,6 +3,7 @@ //! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。 use futures::StreamExt; +use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -141,15 +142,17 @@ impl StreamCheckService { .build() .map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?; + let model_to_test = Self::resolve_test_model(app_type, provider, config); + let result = match app_type { AppType::Claude => { - Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await + Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await } AppType::Codex => { - Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await + Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await } AppType::Gemini => { - Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await + Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await } }; @@ -379,6 +382,48 @@ impl StreamCheckService { AppError::Message(e.to_string()) } } + + fn resolve_test_model( + app_type: &AppType, + provider: &Provider, + config: &StreamCheckConfig, + ) -> String { + match app_type { + AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL") + .unwrap_or_else(|| config.claude_model.clone()), + AppType::Codex => { + Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone()) + } + AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL") + .unwrap_or_else(|| config.gemini_model.clone()), + } + } + + fn extract_env_model(provider: &Provider, key: &str) -> Option { + provider + .settings_config + .get("env") + .and_then(|env| env.get(key)) + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } + + fn extract_codex_model(provider: &Provider) -> Option { + let config_text = provider + .settings_config + .get("config") + .and_then(|value| value.as_str())?; + if config_text.trim().is_empty() { + return None; + } + + let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?; + re.captures(config_text) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().trim().to_string()) + .filter(|value| !value.is_empty()) + } } #[cfg(test)] diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index aaaeb6f1..3b268dbb 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -76,19 +76,8 @@ fn sync_codex_provider_writes_auth_and_config() { let mut config = MultiAppConfig::default(); - // 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML - config.mcp.codex.servers.insert( - "echo-server".into(), - json!({ - "id": "echo-server", - "enabled": true, - "server": { - "type": "stdio", - "command": "echo", - "args": ["hello"] - } - }), - ); + // 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发 + // 此测试仅验证 auth.json 和 config.toml 基础配置的写入 let provider_config = json!({ "auth": { @@ -133,9 +122,10 @@ fn sync_codex_provider_writes_auth_and_config() { ); let toml_text = fs::read_to_string(&config_path).expect("read config.toml"); + // 验证基础配置正确写入 assert!( - toml_text.contains("command = \"echo\""), - "config.toml should contain serialized enabled MCP server" + toml_text.contains("base_url"), + "config.toml should contain base_url from provider config" ); // 当前供应商应同步最新 config 文本 diff --git a/src/App.tsx b/src/App.tsx index ff576426..bf89800c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,9 +43,17 @@ import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; +import { UniversalProviderPanel } from "@/components/universal"; import { Button } from "@/components/ui/button"; -type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; +type View = + | "providers" + | "settings" + | "prompts" + | "skills" + | "mcp" + | "agents" + | "universal"; const DRAG_BAR_HEIGHT = 28; // px const HEADER_HEIGHT = 64; // px @@ -65,6 +73,22 @@ function App() { const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); + // 保存最后一个有效的 provider,用于动画退出期间显示内容 + const lastUsageProviderRef = useRef(null); + const lastEditingProviderRef = useRef(null); + + useEffect(() => { + if (usageProvider) { + lastUsageProviderRef.current = usageProvider; + } + }, [usageProvider]); + + useEffect(() => { + if (editingProvider) { + lastEditingProviderRef.current = editingProvider; + } + }, [editingProvider]); + const promptPanelRef = useRef(null); const mcpPanelRef = useRef(null); const skillsPageRef = useRef(null); @@ -129,6 +153,38 @@ function App() { }; }, [activeApp, refetch]); + // 监听统一供应商同步事件,刷新所有应用的供应商列表 + useEffect(() => { + let unsubscribe: (() => void) | undefined; + + const setupListener = async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + unsubscribe = await listen("universal-provider-synced", async () => { + // 统一供应商同步后刷新所有应用的供应商列表 + // 使用 invalidateQueries 使所有 providers 查询失效 + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + // 同时更新托盘菜单 + try { + await providersApi.updateTrayMenu(); + } catch (error) { + console.error("[App] Failed to update tray menu", error); + } + }); + } catch (error) { + console.error( + "[App] Failed to subscribe universal-provider-synced event", + error, + ); + } + }; + + setupListener(); + return () => { + unsubscribe?.(); + }; + }, [queryClient]); + // 应用启动时检测所有应用的环境变量冲突 useEffect(() => { const checkEnvOnStartup = async () => { @@ -206,6 +262,21 @@ function App() { checkEnvOnSwitch(); }, [activeApp]); + useEffect(() => { + const handleGlobalShortcut = (event: KeyboardEvent) => { + if (event.key !== "," || !(event.metaKey || event.ctrlKey)) { + return; + } + event.preventDefault(); + setCurrentView("settings"); + }; + + window.addEventListener("keydown", handleGlobalShortcut); + return () => { + window.removeEventListener("keydown", handleGlobalShortcut); + }; + }, []); + // 打开网站链接 const handleOpenWebsite = async (url: string) => { try { @@ -368,6 +439,12 @@ function App() { return ( setCurrentView("providers")} /> ); + case "universal": + return ( +
+ +
+ ); default: return (
@@ -499,6 +576,10 @@ function App() { {currentView === "skills" && t("skills.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "agents" && t("agents.title")} + {currentView === "universal" && + t("universalProvider.title", { + defaultValue: "统一供应商", + })}
) : ( @@ -533,7 +614,7 @@ function App() {
{currentView === "prompts" && ( @@ -646,11 +727,7 @@ function App() {
-
- {renderContent()} -
+
{renderContent()}
{ if (!open) { setEditingProvider(null); @@ -673,14 +750,16 @@ function App() { isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} /> - {usageProvider && ( + {lastUsageProviderRef.current && ( setUsageProvider(null)} onSave={(script) => { - void saveUsageScript(usageProvider, script); + if (usageProvider) { + void saveUsageScript(usageProvider, script); + } }} /> )} diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index edf3a155..c5f79b87 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import { createPortal } from "react-dom"; -import { motion } from "framer-motion"; +import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -33,49 +33,57 @@ export const FullScreenPanel: React.FC = ({ }; }, [isOpen]); - if (!isOpen) return null; - return createPortal( - - {/* Header */} -
-
-
- -

{title}

-
-
- - {/* Content */} -
-
- {children} -
-
- - {/* Footer */} - {footer && ( -
+ {isOpen && ( + -
- {footer} + {/* Header */} +
+
+
+ +

{title}

+
-
+ + {/* Content */} +
+
+ {children} +
+
+ + {/* Footer */} + {footer && ( +
+
+ {footer} +
+
+ )} + )} - , + , document.body, ); }; diff --git a/src/components/env/EnvWarningBanner.tsx b/src/components/env/EnvWarningBanner.tsx index 20d4e8d7..da31ce92 100644 --- a/src/components/env/EnvWarningBanner.tsx +++ b/src/components/env/EnvWarningBanner.tsx @@ -191,11 +191,11 @@ export function EnvWarningBanner({
-

+

{t("env.field.value")}: {conflict.varValue}

diff --git a/src/components/mcp/McpWizardModal.tsx b/src/components/mcp/McpWizardModal.tsx index fe4336f7..a5cf5de2 100644 --- a/src/components/mcp/McpWizardModal.tsx +++ b/src/components/mcp/McpWizardModal.tsx @@ -239,7 +239,7 @@ const McpWizardModal: React.FC = ({

{/* Hint */}
-

+

{t("mcp.wizard.hint")}

@@ -248,7 +248,7 @@ const McpWizardModal: React.FC = ({
{/* Type */}
-