Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 621be9a466 | |||
| 937978d68a | |||
| 298d19af89 | |||
| 5fcddbd096 | |||
| 00a94e118f | |||
| c09e0378b9 | |||
| 30004037e5 | |||
| d6ed95078c | |||
| e27b8ee31f | |||
| 3d7b056df1 | |||
| 69f3c78bbf | |||
| 9b1c659f07 | |||
| e1720a0dd1 | |||
| 85998600a1 | |||
| 2b1fd0582e | |||
| dbdaf35770 | |||
| c6f4a54c98 |
@@ -1 +1 @@
|
|||||||
22.12.0
|
v22.4.1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -15,7 +15,7 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
|||||||
|
|
||||||
## ❤️Sponsor
|
## ❤️Sponsor
|
||||||
|
|
||||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|

|
||||||
|
|
||||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
||||||
|
|
||||||
@@ -23,18 +23,19 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
||||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
||||||
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
|
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you’ll receive an extra 10% bonus credit on your first top-up!
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
@@ -47,7 +48,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 Major Update (2025-11-28)**
|
**v3.8.0 Major Update (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## ❤️スポンサー
|
## ❤️スポンサー
|
||||||
|
|
||||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|

|
||||||
|
|
||||||
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
||||||
|
|
||||||
@@ -23,18 +23,20 @@
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
||||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
||||||
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
|
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
|
||||||
|
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
|
|
||||||
## 特長
|
## 特長
|
||||||
|
|
||||||
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## ❤️赞助商
|
## ❤️赞助商
|
||||||
|
|
||||||
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|

|
||||||
|
|
||||||
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
||||||
|
|
||||||
@@ -23,20 +23,20 @@
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
|
||||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
|
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
|
||||||
|
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍,AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
||||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-zh.jpeg" alt="DMXAPI" width="150"></a></td>
|
|
||||||
<td>感谢 DMXAPI(大模型API)赞助了本项目! DMXAPI,一个Key用全球大模型。
|
|
||||||
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
|
|
||||||
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
|
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
**v3.8.0 重大更新(2025-11-28)**
|
**v3.8.0 重大更新(2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -4,7 +4,7 @@
|
|||||||
"rsc": false,
|
"rsc": false,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.cjs",
|
"config": "tailwind.config.js",
|
||||||
"css": "src/index.css",
|
"css": "src/index.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.0-2",
|
"version": "3.9.0-2",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
"build": "pnpm tauri build",
|
"build": "pnpm tauri build",
|
||||||
@@ -28,7 +27,6 @@
|
|||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"code-inspector-plugin": "^1.3.3",
|
|
||||||
"cross-fetch": "^4.1.0",
|
"cross-fetch": "^4.1.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"msw": "^2.11.6",
|
"msw": "^2.11.6",
|
||||||
@@ -36,7 +34,7 @@
|
|||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^5.0.0",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -87,4 +85,4 @@
|
|||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,16 @@
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||||
|
|
||||||
/// 获取 macOS 上的 .app bundle 路径
|
|
||||||
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<std::path::PathBuf> {
|
|
||||||
let path_str = exe_path.to_string_lossy();
|
|
||||||
// 查找 .app/Contents/MacOS/ 模式
|
|
||||||
if let Some(app_pos) = path_str.find(".app/Contents/MacOS/") {
|
|
||||||
let app_bundle_end = app_pos + 4; // ".app" 的结束位置
|
|
||||||
Some(std::path::PathBuf::from(&path_str[..app_bundle_end]))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 初始化 AutoLaunch 实例
|
/// 初始化 AutoLaunch 实例
|
||||||
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||||
let app_name = "CC Switch";
|
let app_name = "CC Switch";
|
||||||
let exe_path =
|
let app_path =
|
||||||
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
|
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
|
||||||
|
|
||||||
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let app_path = exe_path;
|
|
||||||
|
|
||||||
// 使用 AutoLaunchBuilder 消除平台差异
|
// 使用 AutoLaunchBuilder 消除平台差异
|
||||||
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
|
// Windows/Linux: new() 接受 3 参数
|
||||||
// Windows/Linux: 使用注册表/XDG autostart
|
// macOS: new() 接受 4 参数(含 hidden 参数)
|
||||||
|
// Builder 模式自动处理这些差异
|
||||||
let auto_launch = AutoLaunchBuilder::new()
|
let auto_launch = AutoLaunchBuilder::new()
|
||||||
.set_app_name(app_name)
|
.set_app_name(app_name)
|
||||||
.set_app_path(&app_path.to_string_lossy())
|
.set_app_path(&app_path.to_string_lossy())
|
||||||
@@ -67,50 +47,3 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
|
|||||||
.is_enabled()
|
.is_enabled()
|
||||||
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
|
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn test_get_macos_app_bundle_path_valid() {
|
|
||||||
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
|
|
||||||
let result = get_macos_app_bundle_path(exe_path);
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn test_get_macos_app_bundle_path_with_spaces() {
|
|
||||||
let exe_path =
|
|
||||||
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
|
|
||||||
let result = get_macos_app_bundle_path(exe_path);
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
Some(std::path::PathBuf::from(
|
|
||||||
"/Users/test/My Apps/CC Switch.app"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn test_get_macos_app_bundle_path_not_in_bundle() {
|
|
||||||
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
|
|
||||||
let result = get_macos_app_bundle_path(exe_path);
|
|
||||||
assert_eq!(result, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[test]
|
|
||||||
fn test_get_macos_app_bundle_path_dev_build() {
|
|
||||||
// 开发环境下的路径通常不在 .app bundle 内
|
|
||||||
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
|
|
||||||
let result = get_macos_app_bundle_path(exe_path);
|
|
||||||
assert_eq!(result, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -229,97 +229,3 @@ pub fn update_providers_sort_order(
|
|||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 统一供应商(Universal Provider)命令
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
use crate::provider::UniversalProvider;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use tauri::{AppHandle, Emitter};
|
|
||||||
|
|
||||||
/// 统一供应商同步完成事件的 payload
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
|
||||||
pub struct UniversalProviderSyncedEvent {
|
|
||||||
/// 操作类型: "upsert" | "delete" | "sync"
|
|
||||||
pub action: String,
|
|
||||||
/// 统一供应商 ID
|
|
||||||
pub id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 发送统一供应商同步事件,通知前端刷新供应商列表
|
|
||||||
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
|
||||||
let _ = app.emit(
|
|
||||||
"universal-provider-synced",
|
|
||||||
UniversalProviderSyncedEvent {
|
|
||||||
action: action.to_string(),
|
|
||||||
id: id.to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取所有统一供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_universal_providers(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<HashMap<String, UniversalProvider>, String> {
|
|
||||||
ProviderService::list_universal(state.inner()).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个统一供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_universal_provider(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: String,
|
|
||||||
) -> Result<Option<UniversalProvider>, String> {
|
|
||||||
ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加或更新统一供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn upsert_universal_provider(
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
provider: UniversalProvider,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let id = provider.id.clone();
|
|
||||||
let result =
|
|
||||||
ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "upsert", &id);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除统一供应商
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn delete_universal_provider(
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: String,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let result =
|
|
||||||
ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "delete", &id);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同步统一供应商到各应用(手动触发)
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn sync_universal_provider(
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
id: String,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let result =
|
|
||||||
ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// 发送事件通知前端刷新
|
|
||||||
emit_universal_provider_synced(&app, "sync", &id);
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod proxy;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod universal_providers;
|
|
||||||
|
|
||||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||||
// 导出 FailoverQueueItem 供外部使用
|
// 导出 FailoverQueueItem 供外部使用
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
//! 统一供应商 (Universal Provider) DAO
|
|
||||||
//!
|
|
||||||
//! 提供统一供应商的 CRUD 操作。
|
|
||||||
|
|
||||||
use crate::database::{lock_conn, to_json_string, Database};
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::provider::UniversalProvider;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// 统一供应商的 Settings Key
|
|
||||||
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
/// 获取所有统一供应商
|
|
||||||
pub fn get_all_universal_providers(
|
|
||||||
&self,
|
|
||||||
) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let result: Option<String> = stmt
|
|
||||||
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Some(json) => serde_json::from_str(&json)
|
|
||||||
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
|
|
||||||
None => Ok(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个统一供应商
|
|
||||||
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
|
|
||||||
let providers = self.get_all_universal_providers()?;
|
|
||||||
Ok(providers.get(id).cloned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存统一供应商(添加或更新)
|
|
||||||
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
|
|
||||||
let mut providers = self.get_all_universal_providers()?;
|
|
||||||
providers.insert(provider.id.clone(), provider.clone());
|
|
||||||
self.save_all_universal_providers(&providers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除统一供应商
|
|
||||||
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
|
|
||||||
let mut providers = self.get_all_universal_providers()?;
|
|
||||||
let existed = providers.remove(id).is_some();
|
|
||||||
if existed {
|
|
||||||
self.save_all_universal_providers(&providers)?;
|
|
||||||
}
|
|
||||||
Ok(existed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存所有统一供应商(内部方法)
|
|
||||||
fn save_all_universal_providers(
|
|
||||||
&self,
|
|
||||||
providers: &HashMap<String, UniversalProvider>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
let json = to_json_string(providers)?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
|
||||||
[UNIVERSAL_PROVIDERS_KEY, &json],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -113,39 +113,33 @@ impl Database {
|
|||||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 初始化三行数据(每应用不同默认值)
|
// 初始化三行数据(每应用不同默认值)
|
||||||
//
|
conn.execute(
|
||||||
// 兼容旧数据库:
|
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||||
// - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert;
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
// - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
if Self::has_column(conn, "proxy_config", "app_type")? {
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
conn.execute(
|
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||||
"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,
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
circuit_error_rate_threshold, circuit_min_requests)
|
conn.execute(
|
||||||
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
"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,
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
conn.execute(
|
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||||
"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,
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
circuit_error_rate_threshold, circuit_min_requests)
|
conn.execute(
|
||||||
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
"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,
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
conn.execute(
|
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||||
"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,
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
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 表
|
// 9. Provider Health 表
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
|
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
|
||||||
@@ -233,24 +227,6 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 尝试添加基础配置列到 proxy_config 表(兼容 v3.9.0-2 升级)
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 5000",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 尝试添加超时配置列到 proxy_config 表
|
// 尝试添加超时配置列到 proxy_config 表
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||||
@@ -265,14 +241,6 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 兼容:若旧版 proxy_config 仍为单例结构(无 app_type),则在启动时直接转换为三行结构
|
|
||||||
// 说明:user_version=2 时不会再触发 v1->v2 迁移,但新代码查询依赖 app_type 列。
|
|
||||||
if Self::table_exists(conn, "proxy_config")?
|
|
||||||
&& !Self::has_column(conn, "proxy_config", "app_type")?
|
|
||||||
{
|
|
||||||
Self::migrate_proxy_config_to_per_app(conn)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
@@ -437,32 +405,6 @@ impl Database {
|
|||||||
|
|
||||||
// 添加代理超时配置字段
|
// 添加代理超时配置字段
|
||||||
if Self::table_exists(conn, "proxy_config")? {
|
if Self::table_exists(conn, "proxy_config")? {
|
||||||
// 兼容旧版本缺失的基础字段
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"proxy_config",
|
|
||||||
"proxy_enabled",
|
|
||||||
"INTEGER NOT NULL DEFAULT 0",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"proxy_config",
|
|
||||||
"listen_address",
|
|
||||||
"TEXT NOT NULL DEFAULT '127.0.0.1'",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"proxy_config",
|
|
||||||
"listen_port",
|
|
||||||
"INTEGER NOT NULL DEFAULT 5000",
|
|
||||||
)?;
|
|
||||||
Self::add_column_if_missing(
|
|
||||||
conn,
|
|
||||||
"proxy_config",
|
|
||||||
"enable_logging",
|
|
||||||
"INTEGER NOT NULL DEFAULT 1",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
"proxy_config",
|
"proxy_config",
|
||||||
|
|||||||
@@ -201,53 +201,6 @@ fn migration_aligns_column_defaults_and_types() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
|
||||||
|
|
||||||
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
|
||||||
Database::set_user_version(&conn, 2).expect("set user_version");
|
|
||||||
conn.execute_batch(
|
|
||||||
r#"
|
|
||||||
CREATE TABLE proxy_config (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
|
||||||
listen_port INTEGER NOT NULL DEFAULT 5000,
|
|
||||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
||||||
request_timeout INTEGER NOT NULL DEFAULT 300,
|
|
||||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
|
||||||
target_app TEXT NOT NULL DEFAULT 'claude',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
INSERT INTO proxy_config (id, enabled) VALUES (1, 1);
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.expect("seed legacy proxy_config");
|
|
||||||
|
|
||||||
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
|
|
||||||
"proxy_config should be migrated to per-app structure"
|
|
||||||
);
|
|
||||||
|
|
||||||
let count: i32 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
|
||||||
.expect("count rows");
|
|
||||||
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
|
|
||||||
|
|
||||||
// 新结构下应能按 app_type 查询
|
|
||||||
let _: i32 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.expect("query by app_type");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dry_run_does_not_write_to_disk() {
|
fn dry_run_does_not_write_to_disk() {
|
||||||
// Create minimal valid config for migration
|
// Create minimal valid config for migration
|
||||||
|
|||||||
@@ -694,12 +694,6 @@ pub fn run() {
|
|||||||
commands::get_stream_check_config,
|
commands::get_stream_check_config,
|
||||||
commands::save_stream_check_config,
|
commands::save_stream_check_config,
|
||||||
commands::get_tool_versions,
|
commands::get_tool_versions,
|
||||||
// Universal Provider management
|
|
||||||
commands::get_universal_providers,
|
|
||||||
commands::get_universal_provider,
|
|
||||||
commands::upsert_universal_provider,
|
|
||||||
commands::delete_universal_provider,
|
|
||||||
commands::sync_universal_provider,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let app = builder
|
let app = builder
|
||||||
|
|||||||
@@ -359,14 +359,9 @@ pub fn sync_single_server_to_codex(
|
|||||||
let mut doc = if config_path.exists() {
|
let mut doc = if config_path.exists() {
|
||||||
let content =
|
let content =
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
|
content
|
||||||
match content.parse::<toml_edit::DocumentMut>() {
|
.parse::<toml_edit::DocumentMut>()
|
||||||
Ok(doc) => doc,
|
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||||
Err(e) => {
|
|
||||||
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
|
|
||||||
toml_edit::DocumentMut::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toml_edit::DocumentMut::new()
|
toml_edit::DocumentMut::new()
|
||||||
};
|
};
|
||||||
@@ -414,14 +409,9 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
|||||||
let content =
|
let content =
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||||
|
|
||||||
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
|
let mut doc = content
|
||||||
let mut doc = match content.parse::<toml_edit::DocumentMut>() {
|
.parse::<toml_edit::DocumentMut>()
|
||||||
Ok(doc) => doc,
|
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
||||||
Err(e) => {
|
|
||||||
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从正确的位置删除:[mcp_servers]
|
// 从正确的位置删除:[mcp_servers]
|
||||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||||
|
|||||||
@@ -173,286 +173,3 @@ impl ProviderManager {
|
|||||||
&self.providers
|
&self.providers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 统一供应商(Universal Provider)- 跨应用共享配置
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// 统一供应商的应用启用状态
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct UniversalProviderApps {
|
|
||||||
#[serde(default)]
|
|
||||||
pub claude: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub codex: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub gemini: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Claude 模型配置
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct ClaudeModelConfig {
|
|
||||||
/// 主模型
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
/// Haiku 默认模型
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "haikuModel")]
|
|
||||||
pub haiku_model: Option<String>,
|
|
||||||
/// Sonnet 默认模型
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "sonnetModel")]
|
|
||||||
pub sonnet_model: Option<String>,
|
|
||||||
/// Opus 默认模型
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "opusModel")]
|
|
||||||
pub opus_model: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Codex 模型配置
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct CodexModelConfig {
|
|
||||||
/// 模型名称
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
/// 推理强度
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "reasoningEffort")]
|
|
||||||
pub reasoning_effort: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gemini 模型配置
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct GeminiModelConfig {
|
|
||||||
/// 模型名称
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 各应用的模型配置
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct UniversalProviderModels {
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude: Option<ClaudeModelConfig>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub codex: Option<CodexModelConfig>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub gemini: Option<GeminiModelConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 统一供应商(跨应用共享配置)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UniversalProvider {
|
|
||||||
/// 唯一标识
|
|
||||||
pub id: String,
|
|
||||||
/// 供应商名称
|
|
||||||
pub name: String,
|
|
||||||
/// 供应商类型(如 "newapi", "custom")
|
|
||||||
#[serde(rename = "providerType")]
|
|
||||||
pub provider_type: String,
|
|
||||||
/// 应用启用状态
|
|
||||||
pub apps: UniversalProviderApps,
|
|
||||||
/// API 基础地址
|
|
||||||
#[serde(rename = "baseUrl")]
|
|
||||||
pub base_url: String,
|
|
||||||
/// API 密钥
|
|
||||||
#[serde(rename = "apiKey")]
|
|
||||||
pub api_key: String,
|
|
||||||
/// 各应用的模型配置
|
|
||||||
#[serde(default)]
|
|
||||||
pub models: UniversalProviderModels,
|
|
||||||
/// 网站链接
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "websiteUrl")]
|
|
||||||
pub website_url: Option<String>,
|
|
||||||
/// 备注信息
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub notes: Option<String>,
|
|
||||||
/// 图标名称
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub icon: Option<String>,
|
|
||||||
/// 图标颜色
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "iconColor")]
|
|
||||||
pub icon_color: Option<String>,
|
|
||||||
/// 元数据
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub meta: Option<ProviderMeta>,
|
|
||||||
/// 创建时间戳
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: Option<i64>,
|
|
||||||
/// 排序索引
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[serde(rename = "sortIndex")]
|
|
||||||
pub sort_index: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UniversalProvider {
|
|
||||||
/// 创建新的统一供应商
|
|
||||||
pub fn new(
|
|
||||||
id: String,
|
|
||||||
name: String,
|
|
||||||
provider_type: String,
|
|
||||||
base_url: String,
|
|
||||||
api_key: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
provider_type,
|
|
||||||
apps: UniversalProviderApps::default(),
|
|
||||||
base_url,
|
|
||||||
api_key,
|
|
||||||
models: UniversalProviderModels::default(),
|
|
||||||
website_url: None,
|
|
||||||
notes: None,
|
|
||||||
icon: None,
|
|
||||||
icon_color: None,
|
|
||||||
meta: None,
|
|
||||||
created_at: Some(chrono::Utc::now().timestamp_millis()),
|
|
||||||
sort_index: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成 Claude 供应商配置
|
|
||||||
pub fn to_claude_provider(&self) -> Option<Provider> {
|
|
||||||
if !self.apps.claude {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let models = self.models.claude.as_ref();
|
|
||||||
let model = models
|
|
||||||
.and_then(|m| m.model.clone())
|
|
||||||
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
|
||||||
let haiku = models
|
|
||||||
.and_then(|m| m.haiku_model.clone())
|
|
||||||
.unwrap_or_else(|| model.clone());
|
|
||||||
let sonnet = models
|
|
||||||
.and_then(|m| m.sonnet_model.clone())
|
|
||||||
.unwrap_or_else(|| model.clone());
|
|
||||||
let opus = models
|
|
||||||
.and_then(|m| m.opus_model.clone())
|
|
||||||
.unwrap_or_else(|| model.clone());
|
|
||||||
|
|
||||||
let settings_config = serde_json::json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": self.base_url,
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": self.api_key,
|
|
||||||
"ANTHROPIC_MODEL": model,
|
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku,
|
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet,
|
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": opus,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(Provider {
|
|
||||||
id: format!("universal-claude-{}", self.id),
|
|
||||||
name: self.name.clone(),
|
|
||||||
settings_config,
|
|
||||||
website_url: self.website_url.clone(),
|
|
||||||
category: Some("aggregator".to_string()),
|
|
||||||
created_at: self.created_at,
|
|
||||||
sort_index: self.sort_index,
|
|
||||||
notes: self.notes.clone(),
|
|
||||||
meta: self.meta.clone(),
|
|
||||||
icon: self.icon.clone(),
|
|
||||||
icon_color: self.icon_color.clone(),
|
|
||||||
in_failover_queue: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成 Codex 供应商配置
|
|
||||||
pub fn to_codex_provider(&self) -> Option<Provider> {
|
|
||||||
if !self.apps.codex {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let models = self.models.codex.as_ref();
|
|
||||||
let model = models
|
|
||||||
.and_then(|m| m.model.clone())
|
|
||||||
.unwrap_or_else(|| "gpt-4o".to_string());
|
|
||||||
let reasoning_effort = models
|
|
||||||
.and_then(|m| m.reasoning_effort.clone())
|
|
||||||
.unwrap_or_else(|| "high".to_string());
|
|
||||||
|
|
||||||
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API)
|
|
||||||
let codex_base_url = if self.base_url.ends_with("/v1") {
|
|
||||||
self.base_url.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}/v1", self.base_url.trim_end_matches('/'))
|
|
||||||
};
|
|
||||||
|
|
||||||
// 生成 Codex 的 config.toml 内容
|
|
||||||
let config_toml = format!(
|
|
||||||
r#"model_provider = "newapi"
|
|
||||||
model = "{}"
|
|
||||||
model_reasoning_effort = "{}"
|
|
||||||
disable_response_storage = true
|
|
||||||
|
|
||||||
[model_providers.newapi]
|
|
||||||
name = "NewAPI"
|
|
||||||
base_url = "{}"
|
|
||||||
wire_api = "responses"
|
|
||||||
requires_openai_auth = true"#,
|
|
||||||
model, reasoning_effort, codex_base_url
|
|
||||||
);
|
|
||||||
|
|
||||||
let settings_config = serde_json::json!({
|
|
||||||
"auth": {
|
|
||||||
"OPENAI_API_KEY": self.api_key
|
|
||||||
},
|
|
||||||
"config": config_toml
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(Provider {
|
|
||||||
id: format!("universal-codex-{}", self.id),
|
|
||||||
name: self.name.clone(),
|
|
||||||
settings_config,
|
|
||||||
website_url: self.website_url.clone(),
|
|
||||||
category: Some("aggregator".to_string()),
|
|
||||||
created_at: self.created_at,
|
|
||||||
sort_index: self.sort_index,
|
|
||||||
notes: self.notes.clone(),
|
|
||||||
meta: self.meta.clone(),
|
|
||||||
icon: self.icon.clone(),
|
|
||||||
icon_color: self.icon_color.clone(),
|
|
||||||
in_failover_queue: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成 Gemini 供应商配置
|
|
||||||
pub fn to_gemini_provider(&self) -> Option<Provider> {
|
|
||||||
if !self.apps.gemini {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let models = self.models.gemini.as_ref();
|
|
||||||
let model = models
|
|
||||||
.and_then(|m| m.model.clone())
|
|
||||||
.unwrap_or_else(|| "gemini-2.5-pro".to_string());
|
|
||||||
|
|
||||||
let settings_config = serde_json::json!({
|
|
||||||
"env": {
|
|
||||||
"GOOGLE_GEMINI_BASE_URL": self.base_url,
|
|
||||||
"GEMINI_API_KEY": self.api_key,
|
|
||||||
"GEMINI_MODEL": model,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(Provider {
|
|
||||||
id: format!("universal-gemini-{}", self.id),
|
|
||||||
name: self.name.clone(),
|
|
||||||
settings_config,
|
|
||||||
website_url: self.website_url.clone(),
|
|
||||||
category: Some("aggregator".to_string()),
|
|
||||||
created_at: self.created_at,
|
|
||||||
sort_index: self.sort_index,
|
|
||||||
notes: self.notes.clone(),
|
|
||||||
meta: self.meta.clone(),
|
|
||||||
icon: self.icon.clone(),
|
|
||||||
icon_color: self.icon_color.clone(),
|
|
||||||
in_failover_queue: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -146,9 +146,7 @@ impl ConfigService {
|
|||||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||||
|
|
||||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
crate::mcp::sync_enabled_to_codex(config)?;
|
||||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
|
||||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
|
||||||
|
|
||||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||||
|
|||||||
@@ -690,140 +690,3 @@ pub struct ProviderSortUpdate {
|
|||||||
#[serde(rename = "sortIndex")]
|
#[serde(rename = "sortIndex")]
|
||||||
pub sort_index: usize,
|
pub sort_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 统一供应商(Universal Provider)服务方法
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
use crate::provider::UniversalProvider;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
impl ProviderService {
|
|
||||||
/// 获取所有统一供应商
|
|
||||||
pub fn list_universal(
|
|
||||||
state: &AppState,
|
|
||||||
) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
|
||||||
state.db.get_all_universal_providers()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取单个统一供应商
|
|
||||||
pub fn get_universal(
|
|
||||||
state: &AppState,
|
|
||||||
id: &str,
|
|
||||||
) -> Result<Option<UniversalProvider>, AppError> {
|
|
||||||
state.db.get_universal_provider(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps)
|
|
||||||
pub fn upsert_universal(
|
|
||||||
state: &AppState,
|
|
||||||
provider: UniversalProvider,
|
|
||||||
) -> Result<bool, AppError> {
|
|
||||||
// 保存统一供应商
|
|
||||||
state.db.save_universal_provider(&provider)?;
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除统一供应商
|
|
||||||
pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {
|
|
||||||
// 获取统一供应商(用于删除生成的子供应商)
|
|
||||||
let provider = state.db.get_universal_provider(id)?;
|
|
||||||
|
|
||||||
// 删除统一供应商
|
|
||||||
state.db.delete_universal_provider(id)?;
|
|
||||||
|
|
||||||
// 删除生成的子供应商
|
|
||||||
if let Some(p) = provider {
|
|
||||||
if p.apps.claude {
|
|
||||||
let claude_id = format!("universal-claude-{}", id);
|
|
||||||
let _ = state.db.delete_provider("claude", &claude_id);
|
|
||||||
}
|
|
||||||
if p.apps.codex {
|
|
||||||
let codex_id = format!("universal-codex-{}", id);
|
|
||||||
let _ = state.db.delete_provider("codex", &codex_id);
|
|
||||||
}
|
|
||||||
if p.apps.gemini {
|
|
||||||
let gemini_id = format!("universal-gemini-{}", id);
|
|
||||||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 同步统一供应商到各应用
|
|
||||||
pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {
|
|
||||||
let provider = state
|
|
||||||
.db
|
|
||||||
.get_universal_provider(id)?
|
|
||||||
.ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?;
|
|
||||||
|
|
||||||
// 同步到 Claude
|
|
||||||
if let Some(mut claude_provider) = provider.to_claude_provider() {
|
|
||||||
// 合并已有配置
|
|
||||||
if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? {
|
|
||||||
let mut merged = existing.settings_config.clone();
|
|
||||||
Self::merge_json(&mut merged, &claude_provider.settings_config);
|
|
||||||
claude_provider.settings_config = merged;
|
|
||||||
}
|
|
||||||
state.db.save_provider("claude", &claude_provider)?;
|
|
||||||
} else {
|
|
||||||
// 如果禁用了 Claude,删除对应的子供应商
|
|
||||||
let claude_id = format!("universal-claude-{}", id);
|
|
||||||
let _ = state.db.delete_provider("claude", &claude_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步到 Codex
|
|
||||||
if let Some(mut codex_provider) = provider.to_codex_provider() {
|
|
||||||
// 合并已有配置
|
|
||||||
if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? {
|
|
||||||
let mut merged = existing.settings_config.clone();
|
|
||||||
Self::merge_json(&mut merged, &codex_provider.settings_config);
|
|
||||||
codex_provider.settings_config = merged;
|
|
||||||
}
|
|
||||||
state.db.save_provider("codex", &codex_provider)?;
|
|
||||||
} else {
|
|
||||||
let codex_id = format!("universal-codex-{}", id);
|
|
||||||
let _ = state.db.delete_provider("codex", &codex_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步到 Gemini
|
|
||||||
if let Some(mut gemini_provider) = provider.to_gemini_provider() {
|
|
||||||
// 合并已有配置
|
|
||||||
if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? {
|
|
||||||
let mut merged = existing.settings_config.clone();
|
|
||||||
Self::merge_json(&mut merged, &gemini_provider.settings_config);
|
|
||||||
gemini_provider.settings_config = merged;
|
|
||||||
}
|
|
||||||
state.db.save_provider("gemini", &gemini_provider)?;
|
|
||||||
} else {
|
|
||||||
let gemini_id = format!("universal-gemini-{}", id);
|
|
||||||
let _ = state.db.delete_provider("gemini", &gemini_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归合并 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -76,8 +76,19 @@ fn sync_codex_provider_writes_auth_and_config() {
|
|||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
let mut config = MultiAppConfig::default();
|
||||||
|
|
||||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
|
||||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
config.mcp.codex.servers.insert(
|
||||||
|
"echo-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo-server",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["hello"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let provider_config = json!({
|
let provider_config = json!({
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -122,10 +133,9 @@ fn sync_codex_provider_writes_auth_and_config() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
||||||
// 验证基础配置正确写入
|
|
||||||
assert!(
|
assert!(
|
||||||
toml_text.contains("base_url"),
|
toml_text.contains("command = \"echo\""),
|
||||||
"config.toml should contain base_url from provider config"
|
"config.toml should contain serialized enabled MCP server"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 当前供应商应同步最新 config 文本
|
// 当前供应商应同步最新 config 文本
|
||||||
|
|||||||
@@ -43,17 +43,9 @@ import PromptPanel from "@/components/prompts/PromptPanel";
|
|||||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||||
import { UniversalProviderPanel } from "@/components/universal";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
type View =
|
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
||||||
| "providers"
|
|
||||||
| "settings"
|
|
||||||
| "prompts"
|
|
||||||
| "skills"
|
|
||||||
| "mcp"
|
|
||||||
| "agents"
|
|
||||||
| "universal";
|
|
||||||
|
|
||||||
const DRAG_BAR_HEIGHT = 28; // px
|
const DRAG_BAR_HEIGHT = 28; // px
|
||||||
const HEADER_HEIGHT = 64; // px
|
const HEADER_HEIGHT = 64; // px
|
||||||
@@ -153,38 +145,6 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [activeApp, refetch]);
|
}, [activeApp, refetch]);
|
||||||
|
|
||||||
// 监听统一供应商同步事件,刷新所有应用的供应商列表
|
|
||||||
useEffect(() => {
|
|
||||||
let unsubscribe: (() => void) | undefined;
|
|
||||||
|
|
||||||
const setupListener = async () => {
|
|
||||||
try {
|
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
|
||||||
unsubscribe = await listen("universal-provider-synced", async () => {
|
|
||||||
// 统一供应商同步后刷新所有应用的供应商列表
|
|
||||||
// 使用 invalidateQueries 使所有 providers 查询失效
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["providers"] });
|
|
||||||
// 同时更新托盘菜单
|
|
||||||
try {
|
|
||||||
await providersApi.updateTrayMenu();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[App] Failed to update tray menu", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[App] Failed to subscribe universal-provider-synced event",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setupListener();
|
|
||||||
return () => {
|
|
||||||
unsubscribe?.();
|
|
||||||
};
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
// 应用启动时检测所有应用的环境变量冲突
|
// 应用启动时检测所有应用的环境变量冲突
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkEnvOnStartup = async () => {
|
const checkEnvOnStartup = async () => {
|
||||||
@@ -262,21 +222,6 @@ function App() {
|
|||||||
checkEnvOnSwitch();
|
checkEnvOnSwitch();
|
||||||
}, [activeApp]);
|
}, [activeApp]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
setCurrentView("settings");
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleGlobalShortcut);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleGlobalShortcut);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 打开网站链接
|
// 打开网站链接
|
||||||
const handleOpenWebsite = async (url: string) => {
|
const handleOpenWebsite = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -419,12 +364,6 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||||
);
|
);
|
||||||
case "universal":
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-[56rem] px-5 pt-4">
|
|
||||||
<UniversalProviderPanel />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
@@ -555,10 +494,6 @@ function App() {
|
|||||||
{currentView === "skills" && t("skills.title")}
|
{currentView === "skills" && t("skills.title")}
|
||||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||||
{currentView === "agents" && t("agents.title")}
|
{currentView === "agents" && t("agents.title")}
|
||||||
{currentView === "universal" &&
|
|
||||||
t("universalProvider.title", {
|
|
||||||
defaultValue: "统一供应商",
|
|
||||||
})}
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -191,11 +191,11 @@ export function EnvWarningBanner({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<label
|
<label
|
||||||
htmlFor={key}
|
htmlFor={key}
|
||||||
className="block text-sm font-medium text-foreground cursor-pointer"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||||
>
|
>
|
||||||
{conflict.varName}
|
{conflict.varName}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground mt-1 break-all">
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
|
||||||
{t("env.field.value")}: {conflict.varValue}
|
{t("env.field.value")}: {conflict.varValue}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
{/* Hint */}
|
{/* Hint */}
|
||||||
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
|
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{t("mcp.wizard.hint")}
|
{t("mcp.wizard.hint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +248,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
<div className="space-y-4 min-h-[400px]">
|
<div className="space-y-4 min-h-[400px]">
|
||||||
{/* Type */}
|
{/* Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
<label className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.type")} <span className="text-red-500">*</span>
|
{t("mcp.wizard.type")} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
@@ -262,7 +262,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
}
|
}
|
||||||
className="w-4 h-4 accent-blue-500"
|
className="w-4 h-4 accent-blue-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeStdio")}
|
{t("mcp.wizard.typeStdio")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -276,7 +276,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
}
|
}
|
||||||
className="w-4 h-4 accent-blue-500"
|
className="w-4 h-4 accent-blue-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeHttp")}
|
{t("mcp.wizard.typeHttp")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -290,7 +290,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
}
|
}
|
||||||
className="w-4 h-4 accent-blue-500"
|
className="w-4 h-4 accent-blue-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.typeSse")}
|
{t("mcp.wizard.typeSse")}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -299,7 +299,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -317,7 +317,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* Command */}
|
{/* Command */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.command")}{" "}
|
{t("mcp.wizard.command")}{" "}
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -333,7 +333,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
|
|
||||||
{/* Args */}
|
{/* Args */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.args")}
|
{t("mcp.wizard.args")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -341,13 +341,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardArgs(e.target.value)}
|
onChange={(e) => setWizardArgs(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.argsPlaceholder")}
|
placeholder={t("mcp.wizard.argsPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Env */}
|
{/* Env */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.env")}
|
{t("mcp.wizard.env")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -355,7 +355,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardEnv(e.target.value)}
|
onChange={(e) => setWizardEnv(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.envPlaceholder")}
|
placeholder={t("mcp.wizard.envPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -366,7 +366,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* URL */}
|
{/* URL */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.url")}{" "}
|
{t("mcp.wizard.url")}{" "}
|
||||||
<span className="text-red-500">*</span>
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -382,7 +382,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
|
|
||||||
{/* Headers */}
|
{/* Headers */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.headers")}
|
{t("mcp.wizard.headers")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -390,7 +390,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
onChange={(e) => setWizardHeaders(e.target.value)}
|
onChange={(e) => setWizardHeaders(e.target.value)}
|
||||||
placeholder={t("mcp.wizard.headersPlaceholder")}
|
placeholder={t("mcp.wizard.headersPlaceholder")}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -404,7 +404,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
|||||||
wizardUrl ||
|
wizardUrl ||
|
||||||
wizardHeaders) && (
|
wizardHeaders) && (
|
||||||
<div className="space-y-2 border-t border-border-default pt-4">
|
<div className="space-y-2 border-t border-border-default pt-4">
|
||||||
<h3 className="text-sm font-medium text-foreground">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("mcp.wizard.preview")}
|
{t("mcp.wizard.preview")}
|
||||||
</h3>
|
</h3>
|
||||||
<pre className="overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300">
|
<pre className="overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300">
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
import type { Provider, CustomEndpoint, UniversalProvider } from "@/types";
|
import type { Provider, CustomEndpoint } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { universalProvidersApi } from "@/lib/api";
|
|
||||||
import {
|
import {
|
||||||
ProviderForm,
|
ProviderForm,
|
||||||
type ProviderFormValues,
|
type ProviderFormValues,
|
||||||
} from "@/components/providers/forms/ProviderForm";
|
} from "@/components/providers/forms/ProviderForm";
|
||||||
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
|
|
||||||
import { UniversalProviderPanel } from "@/components/universal";
|
|
||||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
|
||||||
|
|
||||||
interface AddProviderDialogProps {
|
interface AddProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -33,46 +27,6 @@ export function AddProviderDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddProviderDialogProps) {
|
}: AddProviderDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
|
|
||||||
"app-specific",
|
|
||||||
);
|
|
||||||
const [universalFormOpen, setUniversalFormOpen] = useState(false);
|
|
||||||
const [selectedUniversalPreset, setSelectedUniversalPreset] =
|
|
||||||
useState<UniversalProviderPreset | null>(null);
|
|
||||||
|
|
||||||
// Handle universal provider save
|
|
||||||
const handleUniversalProviderSave = useCallback(
|
|
||||||
async (provider: UniversalProvider) => {
|
|
||||||
try {
|
|
||||||
await universalProvidersApi.upsert(provider);
|
|
||||||
toast.success(
|
|
||||||
t("universalProvider.addSuccess", {
|
|
||||||
defaultValue: "统一供应商添加成功",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setUniversalFormOpen(false);
|
|
||||||
setSelectedUniversalPreset(null);
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[AddProviderDialog] Failed to save universal provider",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
toast.error(
|
|
||||||
t("universalProvider.addFailed", {
|
|
||||||
defaultValue: "统一供应商添加失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t, onOpenChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Close universal form and return to main dialog
|
|
||||||
const handleUniversalFormClose = useCallback(() => {
|
|
||||||
setUniversalFormOpen(false);
|
|
||||||
setSelectedUniversalPreset(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (values: ProviderFormValues) => {
|
async (values: ProviderFormValues) => {
|
||||||
@@ -202,86 +156,46 @@ export function AddProviderDialog({
|
|||||||
[appId, onSubmit, onOpenChange],
|
[appId, onSubmit, onOpenChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 动态 footer:根据当前 Tab 显示不同按钮
|
const submitLabel =
|
||||||
const footer =
|
appId === "claude"
|
||||||
activeTab === "app-specific" ? (
|
? t("provider.addClaudeProvider")
|
||||||
<>
|
: appId === "codex"
|
||||||
<Button
|
? t("provider.addCodexProvider")
|
||||||
variant="outline"
|
: t("provider.addGeminiProvider");
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
const footer = (
|
||||||
>
|
<>
|
||||||
{t("common.cancel")}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
onClick={() => onOpenChange(false)}
|
||||||
type="submit"
|
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||||
form="provider-form"
|
>
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
{t("common.cancel")}
|
||||||
>
|
</Button>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Button
|
||||||
{t("common.add")}
|
type="submit"
|
||||||
</Button>
|
form="provider-form"
|
||||||
</>
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
) : (
|
>
|
||||||
<>
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
<Button
|
{t("common.add")}
|
||||||
variant="outline"
|
</Button>
|
||||||
onClick={() => onOpenChange(false)}
|
</>
|
||||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
);
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setUniversalFormOpen(true)}
|
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
{t("universalProvider.add")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FullScreenPanel
|
<FullScreenPanel
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
title={t("provider.addNewProvider")}
|
title={submitLabel}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
>
|
>
|
||||||
<Tabs
|
<ProviderForm
|
||||||
value={activeTab}
|
appId={appId}
|
||||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
submitLabel={t("common.add")}
|
||||||
>
|
onSubmit={handleSubmit}
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
onCancel={() => onOpenChange(false)}
|
||||||
<TabsTrigger value="app-specific">
|
showButtons={false}
|
||||||
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="universal">
|
|
||||||
{t("provider.tabUniversal")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="app-specific" className="mt-0">
|
|
||||||
<ProviderForm
|
|
||||||
appId={appId}
|
|
||||||
submitLabel={t("common.add")}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onCancel={() => onOpenChange(false)}
|
|
||||||
showButtons={false}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="universal" className="mt-0">
|
|
||||||
<UniversalProviderPanel />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Universal Provider Form Modal */}
|
|
||||||
<UniversalProviderFormModal
|
|
||||||
isOpen={universalFormOpen}
|
|
||||||
onClose={handleUniversalFormClose}
|
|
||||||
onSave={handleUniversalProviderSave}
|
|
||||||
initialPreset={selectedUniversalPreset}
|
|
||||||
/>
|
/>
|
||||||
</FullScreenPanel>
|
</FullScreenPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
|||||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||||
disabled
|
disabled
|
||||||
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
|
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
|
||||||
: "border-border-default bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
|
: "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-foreground">
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
{label} {required && "*"}
|
{label} {required && "*"}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -55,7 +58,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleShowKey}
|
onClick={toggleShowKey}
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
|
||||||
>
|
>
|
||||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="codexAuth"
|
htmlFor="codexAuth"
|
||||||
className="block text-sm font-medium text-foreground"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
{t("codexConfig.authJson")}
|
{t("codexConfig.authJson")}
|
||||||
</label>
|
</label>
|
||||||
@@ -67,7 +67,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && (
|
{!error && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{t("codexConfig.authJsonHint")}
|
{t("codexConfig.authJsonHint")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -120,12 +120,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label
|
<label
|
||||||
htmlFor="codexConfig"
|
htmlFor="codexConfig"
|
||||||
className="block text-sm font-medium text-foreground"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
{t("codexConfig.configToml")}
|
{t("codexConfig.configToml")}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useCommonConfig}
|
checked={useCommonConfig}
|
||||||
@@ -167,7 +167,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!configError && (
|
{!configError && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{t("codexConfig.configTomlHint")}
|
{t("codexConfig.configTomlHint")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function CodexFormFields({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="codexModelName"
|
htmlFor="codexModelName"
|
||||||
className="block text-sm font-medium text-foreground"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
{t("codexConfig.modelName", { defaultValue: "模型名称" })}
|
||||||
</label>
|
</label>
|
||||||
@@ -110,9 +110,9 @@ export function CodexFormFields({
|
|||||||
placeholder={t("codexConfig.modelNamePlaceholder", {
|
placeholder={t("codexConfig.modelNamePlaceholder", {
|
||||||
defaultValue: "例如: gpt-5-codex",
|
defaultValue: "例如: gpt-5-codex",
|
||||||
})}
|
})}
|
||||||
className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
|
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{t("codexConfig.modelNameHint", {
|
{t("codexConfig.modelNameHint", {
|
||||||
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="geminiEnv"
|
htmlFor="geminiEnv"
|
||||||
className="block text-sm font-medium text-foreground"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||||
</label>
|
</label>
|
||||||
@@ -69,7 +69,7 @@ GEMINI_MODEL=gemini-3-pro-preview`}
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && (
|
{!error && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{t("geminiConfig.envFileHint", {
|
{t("geminiConfig.envFileHint", {
|
||||||
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
|
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
|
||||||
})}
|
})}
|
||||||
@@ -124,14 +124,14 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label
|
<label
|
||||||
htmlFor="geminiConfig"
|
htmlFor="geminiConfig"
|
||||||
className="block text-sm font-medium text-foreground"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
{t("geminiConfig.configJson", {
|
{t("geminiConfig.configJson", {
|
||||||
defaultValue: "配置文件 (config.json)",
|
defaultValue: "配置文件 (config.json)",
|
||||||
})}
|
})}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={useCommonConfig}
|
checked={useCommonConfig}
|
||||||
@@ -180,7 +180,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!configError && (
|
{!configError && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{t("geminiConfig.configJsonHint", {
|
{t("geminiConfig.configJsonHint", {
|
||||||
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
geminiProviderPresets,
|
geminiProviderPresets,
|
||||||
type GeminiProviderPreset,
|
type GeminiProviderPreset,
|
||||||
} from "@/config/geminiProviderPresets";
|
} from "@/config/geminiProviderPresets";
|
||||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
|
||||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||||
@@ -73,8 +72,6 @@ interface ProviderFormProps {
|
|||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
onSubmit: (values: ProviderFormValues) => void;
|
onSubmit: (values: ProviderFormValues) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
|
|
||||||
onManageUniversalProviders?: () => void;
|
|
||||||
initialData?: {
|
initialData?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
@@ -94,8 +91,6 @@ export function ProviderForm({
|
|||||||
submitLabel,
|
submitLabel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
onUniversalPresetSelect,
|
|
||||||
onManageUniversalProviders,
|
|
||||||
initialData,
|
initialData,
|
||||||
showButtons = true,
|
showButtons = true,
|
||||||
}: ProviderFormProps) {
|
}: ProviderFormProps) {
|
||||||
@@ -808,8 +803,6 @@ export function ProviderForm({
|
|||||||
categoryKeys={categoryKeys}
|
categoryKeys={categoryKeys}
|
||||||
presetCategoryLabels={presetCategoryLabels}
|
presetCategoryLabels={presetCategoryLabels}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
onUniversalPresetSelect={onUniversalPresetSelect}
|
|
||||||
onManageUniversalProviders={onManageUniversalProviders}
|
|
||||||
category={category}
|
category={category}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FormLabel } from "@/components/ui/form";
|
import { FormLabel } from "@/components/ui/form";
|
||||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||||
import { Zap, Star, Layers, Settings2 } from "lucide-react";
|
import { Zap, Star } from "lucide-react";
|
||||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||||
import type { ProviderCategory } from "@/types";
|
import type { ProviderCategory } from "@/types";
|
||||||
import {
|
|
||||||
universalProviderPresets,
|
|
||||||
type UniversalProviderPreset,
|
|
||||||
} from "@/config/universalProviderPresets";
|
|
||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
|
||||||
|
|
||||||
type PresetEntry = {
|
type PresetEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,8 +18,6 @@ interface ProviderPresetSelectorProps {
|
|||||||
categoryKeys: string[];
|
categoryKeys: string[];
|
||||||
presetCategoryLabels: Record<string, string>;
|
presetCategoryLabels: Record<string, string>;
|
||||||
onPresetChange: (value: string) => void;
|
onPresetChange: (value: string) => void;
|
||||||
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
|
|
||||||
onManageUniversalProviders?: () => void;
|
|
||||||
category?: ProviderCategory; // 当前选中的分类
|
category?: ProviderCategory; // 当前选中的分类
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +27,6 @@ export function ProviderPresetSelector({
|
|||||||
categoryKeys,
|
categoryKeys,
|
||||||
presetCategoryLabels,
|
presetCategoryLabels,
|
||||||
onPresetChange,
|
onPresetChange,
|
||||||
onUniversalPresetSelect,
|
|
||||||
onManageUniversalProviders,
|
|
||||||
category,
|
category,
|
||||||
}: ProviderPresetSelectorProps) {
|
}: ProviderPresetSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -173,49 +164,6 @@ export function ProviderPresetSelector({
|
|||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统一供应商预设(新的一行) */}
|
|
||||||
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{universalProviderPresets.map((preset) => (
|
|
||||||
<button
|
|
||||||
key={`universal-${preset.providerType}`}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onUniversalPresetSelect(preset)}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative"
|
|
||||||
title={t("universalProvider.hint", {
|
|
||||||
defaultValue:
|
|
||||||
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ProviderIcon icon={preset.icon} name={preset.name} size={14} />
|
|
||||||
{preset.name}
|
|
||||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
|
||||||
<Layers className="h-2.5 w-2.5" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{/* 管理统一供应商按钮 */}
|
|
||||||
{onManageUniversalProviders && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onManageUniversalProviders}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80"
|
|
||||||
title={t("universalProvider.manage", {
|
|
||||||
defaultValue: "管理统一供应商",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
{t("universalProvider.manage", {
|
|
||||||
defaultValue: "管理",
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
|
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function EndpointField({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onManageClick}
|
onClick={onManageClick}
|
||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
>
|
>
|
||||||
<Zap className="h-3.5 w-3.5" />
|
<Zap className="h-3.5 w-3.5" />
|
||||||
{manageButtonLabel || defaultManageLabel}
|
{manageButtonLabel || defaultManageLabel}
|
||||||
|
|||||||
@@ -231,10 +231,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
</div>
|
</div>
|
||||||
) : skills.length === 0 ? (
|
) : skills.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
<p className="text-lg font-medium text-foreground">
|
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("skills.empty")}
|
{t("skills.empty")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{t("skills.emptyDescription")}
|
{t("skills.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
@@ -306,10 +306,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
{/* 技能列表或无结果提示 */}
|
{/* 技能列表或无结果提示 */}
|
||||||
{filteredSkills.length === 0 ? (
|
{filteredSkills.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||||
<p className="text-lg font-medium text-foreground">
|
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||||
{t("skills.noResults")}
|
{t("skills.noResults")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{t("skills.emptyDescription")}
|
{t("skills.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Edit2, Trash2, RefreshCw, Globe } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
|
||||||
import type { UniversalProvider } from "@/types";
|
|
||||||
|
|
||||||
interface UniversalProviderCardProps {
|
|
||||||
provider: UniversalProvider;
|
|
||||||
onEdit: (provider: UniversalProvider) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onSync: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UniversalProviderCard({
|
|
||||||
provider,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onSync,
|
|
||||||
}: UniversalProviderCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 获取启用的应用列表
|
|
||||||
const enabledApps: string[] = [
|
|
||||||
provider.apps.claude ? "Claude" : null,
|
|
||||||
provider.apps.codex ? "Codex" : null,
|
|
||||||
provider.apps.gemini ? "Gemini" : null,
|
|
||||||
].filter((app): app is string => app !== null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group relative rounded-xl border border-border/50 bg-card p-4 transition-all hover:border-border hover:shadow-md">
|
|
||||||
{/* 头部:图标和名称 */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent">
|
|
||||||
<ProviderIcon icon={provider.icon} name={provider.name} size={24} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">{provider.name}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{provider.providerType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => onSync(provider.id)}
|
|
||||||
title={t("universalProvider.sync", { defaultValue: "同步到应用" })}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => onEdit(provider)}
|
|
||||||
title={t("common.edit", { defaultValue: "编辑" })}
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => onDelete(provider.id)}
|
|
||||||
title={t("common.delete", { defaultValue: "删除" })}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 配置信息 */}
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{/* Base URL */}
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<span className="truncate text-muted-foreground">
|
|
||||||
{provider.baseUrl || "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 启用的应用 */}
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{enabledApps.map((app) => (
|
|
||||||
<span
|
|
||||||
key={app}
|
|
||||||
className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
|
||||||
>
|
|
||||||
{app}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{enabledApps.length === 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("universalProvider.noAppsEnabled", {
|
|
||||||
defaultValue: "未启用任何应用",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 备注 */}
|
|
||||||
{provider.notes && (
|
|
||||||
<p className="mt-3 text-xs text-muted-foreground line-clamp-2">
|
|
||||||
{provider.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Eye, EyeOff, RefreshCw } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
import type { UniversalProvider, UniversalProviderModels } from "@/types";
|
|
||||||
import {
|
|
||||||
universalProviderPresets,
|
|
||||||
createUniversalProviderFromPreset,
|
|
||||||
type UniversalProviderPreset,
|
|
||||||
} from "@/config/universalProviderPresets";
|
|
||||||
|
|
||||||
interface UniversalProviderFormModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (provider: UniversalProvider) => void;
|
|
||||||
onSaveAndSync?: (provider: UniversalProvider) => void;
|
|
||||||
editingProvider?: UniversalProvider | null;
|
|
||||||
initialPreset?: UniversalProviderPreset | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UniversalProviderFormModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
onSaveAndSync,
|
|
||||||
editingProvider,
|
|
||||||
initialPreset,
|
|
||||||
}: UniversalProviderFormModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isEditMode = !!editingProvider;
|
|
||||||
|
|
||||||
// 表单状态
|
|
||||||
const [selectedPreset, setSelectedPreset] =
|
|
||||||
useState<UniversalProviderPreset | null>(null);
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [baseUrl, setBaseUrl] = useState("");
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
|
||||||
const [websiteUrl, setWebsiteUrl] = useState("");
|
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
|
|
||||||
// 应用启用状态
|
|
||||||
const [claudeEnabled, setClaudeEnabled] = useState(true);
|
|
||||||
const [codexEnabled, setCodexEnabled] = useState(true);
|
|
||||||
const [geminiEnabled, setGeminiEnabled] = useState(true);
|
|
||||||
|
|
||||||
// 模型配置
|
|
||||||
const [models, setModels] = useState<UniversalProviderModels>({});
|
|
||||||
|
|
||||||
// 保存并同步确认弹窗
|
|
||||||
const [syncConfirmOpen, setSyncConfirmOpen] = useState(false);
|
|
||||||
const [pendingProvider, setPendingProvider] =
|
|
||||||
useState<UniversalProvider | null>(null);
|
|
||||||
|
|
||||||
// 初始化表单
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingProvider) {
|
|
||||||
// 编辑模式:加载现有数据
|
|
||||||
setName(editingProvider.name);
|
|
||||||
setBaseUrl(editingProvider.baseUrl);
|
|
||||||
setApiKey(editingProvider.apiKey);
|
|
||||||
setWebsiteUrl(editingProvider.websiteUrl || "");
|
|
||||||
setNotes(editingProvider.notes || "");
|
|
||||||
setClaudeEnabled(editingProvider.apps.claude);
|
|
||||||
setCodexEnabled(editingProvider.apps.codex);
|
|
||||||
setGeminiEnabled(editingProvider.apps.gemini);
|
|
||||||
setModels(editingProvider.models || {});
|
|
||||||
|
|
||||||
// 尝试匹配预设
|
|
||||||
const preset = universalProviderPresets.find(
|
|
||||||
(p) => p.providerType === editingProvider.providerType,
|
|
||||||
);
|
|
||||||
setSelectedPreset(preset || null);
|
|
||||||
} else {
|
|
||||||
// 新建模式:使用传入的预设或默认选择第一个预设
|
|
||||||
const defaultPreset = initialPreset || universalProviderPresets[0];
|
|
||||||
setSelectedPreset(defaultPreset);
|
|
||||||
setName(defaultPreset.name);
|
|
||||||
setBaseUrl("");
|
|
||||||
setApiKey("");
|
|
||||||
setWebsiteUrl(defaultPreset.websiteUrl || "");
|
|
||||||
setNotes("");
|
|
||||||
setClaudeEnabled(defaultPreset.defaultApps.claude);
|
|
||||||
setCodexEnabled(defaultPreset.defaultApps.codex);
|
|
||||||
setGeminiEnabled(defaultPreset.defaultApps.gemini);
|
|
||||||
setModels(JSON.parse(JSON.stringify(defaultPreset.defaultModels)));
|
|
||||||
}
|
|
||||||
}, [editingProvider, initialPreset, isOpen]);
|
|
||||||
|
|
||||||
// 选择预设
|
|
||||||
const handlePresetSelect = useCallback(
|
|
||||||
(preset: UniversalProviderPreset) => {
|
|
||||||
setSelectedPreset(preset);
|
|
||||||
if (!isEditMode) {
|
|
||||||
setName(preset.name);
|
|
||||||
setClaudeEnabled(preset.defaultApps.claude);
|
|
||||||
setCodexEnabled(preset.defaultApps.codex);
|
|
||||||
setGeminiEnabled(preset.defaultApps.gemini);
|
|
||||||
setModels(JSON.parse(JSON.stringify(preset.defaultModels)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isEditMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新模型配置
|
|
||||||
const updateModel = useCallback(
|
|
||||||
(app: "claude" | "codex" | "gemini", field: string, value: string) => {
|
|
||||||
setModels((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[app]: {
|
|
||||||
...(prev[app] || {}),
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 计算 Claude 配置 JSON 预览
|
|
||||||
const claudeConfigJson = useMemo(() => {
|
|
||||||
if (!claudeEnabled) return null;
|
|
||||||
const model = models.claude?.model || "claude-sonnet-4-20250514";
|
|
||||||
const haiku = models.claude?.haikuModel || "claude-haiku-4-20250514";
|
|
||||||
const sonnet = models.claude?.sonnetModel || "claude-sonnet-4-20250514";
|
|
||||||
const opus = models.claude?.opusModel || "claude-sonnet-4-20250514";
|
|
||||||
return {
|
|
||||||
env: {
|
|
||||||
ANTHROPIC_BASE_URL: baseUrl,
|
|
||||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
||||||
ANTHROPIC_MODEL: model,
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: haiku,
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnet,
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: opus,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [claudeEnabled, baseUrl, apiKey, models.claude]);
|
|
||||||
|
|
||||||
// 计算 Codex 配置 JSON 预览
|
|
||||||
const codexConfigJson = useMemo(() => {
|
|
||||||
if (!codexEnabled) return null;
|
|
||||||
const model = models.codex?.model || "gpt-4o";
|
|
||||||
const reasoningEffort = models.codex?.reasoningEffort || "high";
|
|
||||||
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API)
|
|
||||||
const codexBaseUrl = baseUrl.endsWith("/v1")
|
|
||||||
? baseUrl
|
|
||||||
: `${baseUrl.replace(/\/+$/, "")}/v1`;
|
|
||||||
const configToml = `model_provider = "newapi"
|
|
||||||
model = "${model}"
|
|
||||||
model_reasoning_effort = "${reasoningEffort}"
|
|
||||||
disable_response_storage = true
|
|
||||||
|
|
||||||
[model_providers.newapi]
|
|
||||||
name = "NewAPI"
|
|
||||||
base_url = "${codexBaseUrl}"
|
|
||||||
wire_api = "responses"
|
|
||||||
requires_openai_auth = true`;
|
|
||||||
return {
|
|
||||||
auth: {
|
|
||||||
OPENAI_API_KEY: apiKey,
|
|
||||||
},
|
|
||||||
config: configToml,
|
|
||||||
};
|
|
||||||
}, [codexEnabled, baseUrl, apiKey, models.codex]);
|
|
||||||
|
|
||||||
// 计算 Gemini 配置 JSON 预览
|
|
||||||
const geminiConfigJson = useMemo(() => {
|
|
||||||
if (!geminiEnabled) return null;
|
|
||||||
const model = models.gemini?.model || "gemini-2.5-pro";
|
|
||||||
return {
|
|
||||||
env: {
|
|
||||||
GOOGLE_GEMINI_BASE_URL: baseUrl,
|
|
||||||
GEMINI_API_KEY: apiKey,
|
|
||||||
GEMINI_MODEL: model,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [geminiEnabled, baseUrl, apiKey, models.gemini]);
|
|
||||||
|
|
||||||
// 提交表单
|
|
||||||
const handleSubmit = useCallback(() => {
|
|
||||||
if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider: UniversalProvider = editingProvider
|
|
||||||
? {
|
|
||||||
...editingProvider,
|
|
||||||
name: name.trim(),
|
|
||||||
baseUrl: baseUrl.trim(),
|
|
||||||
apiKey: apiKey.trim(),
|
|
||||||
websiteUrl: websiteUrl.trim() || undefined,
|
|
||||||
notes: notes.trim() || undefined,
|
|
||||||
apps: {
|
|
||||||
claude: claudeEnabled,
|
|
||||||
codex: codexEnabled,
|
|
||||||
gemini: geminiEnabled,
|
|
||||||
},
|
|
||||||
models,
|
|
||||||
}
|
|
||||||
: createUniversalProviderFromPreset(
|
|
||||||
selectedPreset || universalProviderPresets[0],
|
|
||||||
crypto.randomUUID(),
|
|
||||||
baseUrl.trim(),
|
|
||||||
apiKey.trim(),
|
|
||||||
name.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果是新建,更新应用启用状态和模型
|
|
||||||
if (!editingProvider) {
|
|
||||||
provider.apps = {
|
|
||||||
claude: claudeEnabled,
|
|
||||||
codex: codexEnabled,
|
|
||||||
gemini: geminiEnabled,
|
|
||||||
};
|
|
||||||
provider.models = models;
|
|
||||||
provider.websiteUrl = websiteUrl.trim() || undefined;
|
|
||||||
provider.notes = notes.trim() || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSave(provider);
|
|
||||||
onClose();
|
|
||||||
}, [
|
|
||||||
editingProvider,
|
|
||||||
name,
|
|
||||||
baseUrl,
|
|
||||||
apiKey,
|
|
||||||
websiteUrl,
|
|
||||||
notes,
|
|
||||||
claudeEnabled,
|
|
||||||
codexEnabled,
|
|
||||||
geminiEnabled,
|
|
||||||
models,
|
|
||||||
selectedPreset,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 构建 provider 对象的辅助函数
|
|
||||||
const buildProvider = useCallback((): UniversalProvider | null => {
|
|
||||||
if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider: UniversalProvider = editingProvider
|
|
||||||
? {
|
|
||||||
...editingProvider,
|
|
||||||
name: name.trim(),
|
|
||||||
baseUrl: baseUrl.trim(),
|
|
||||||
apiKey: apiKey.trim(),
|
|
||||||
websiteUrl: websiteUrl.trim() || undefined,
|
|
||||||
notes: notes.trim() || undefined,
|
|
||||||
apps: {
|
|
||||||
claude: claudeEnabled,
|
|
||||||
codex: codexEnabled,
|
|
||||||
gemini: geminiEnabled,
|
|
||||||
},
|
|
||||||
models,
|
|
||||||
}
|
|
||||||
: createUniversalProviderFromPreset(
|
|
||||||
selectedPreset || universalProviderPresets[0],
|
|
||||||
crypto.randomUUID(),
|
|
||||||
baseUrl.trim(),
|
|
||||||
apiKey.trim(),
|
|
||||||
name.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果是新建,更新应用启用状态和模型
|
|
||||||
if (!editingProvider) {
|
|
||||||
provider.apps = {
|
|
||||||
claude: claudeEnabled,
|
|
||||||
codex: codexEnabled,
|
|
||||||
gemini: geminiEnabled,
|
|
||||||
};
|
|
||||||
provider.models = models;
|
|
||||||
provider.websiteUrl = websiteUrl.trim() || undefined;
|
|
||||||
provider.notes = notes.trim() || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider;
|
|
||||||
}, [
|
|
||||||
editingProvider,
|
|
||||||
name,
|
|
||||||
baseUrl,
|
|
||||||
apiKey,
|
|
||||||
websiteUrl,
|
|
||||||
notes,
|
|
||||||
claudeEnabled,
|
|
||||||
codexEnabled,
|
|
||||||
geminiEnabled,
|
|
||||||
models,
|
|
||||||
selectedPreset,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 打开保存并同步确认弹窗
|
|
||||||
const handleSaveAndSyncClick = useCallback(() => {
|
|
||||||
const provider = buildProvider();
|
|
||||||
if (!provider || !onSaveAndSync) return;
|
|
||||||
|
|
||||||
setPendingProvider(provider);
|
|
||||||
setSyncConfirmOpen(true);
|
|
||||||
}, [buildProvider, onSaveAndSync]);
|
|
||||||
|
|
||||||
// 确认保存并同步
|
|
||||||
const confirmSaveAndSync = useCallback(() => {
|
|
||||||
if (!pendingProvider || !onSaveAndSync) return;
|
|
||||||
|
|
||||||
onSaveAndSync(pendingProvider);
|
|
||||||
setSyncConfirmOpen(false);
|
|
||||||
setPendingProvider(null);
|
|
||||||
onClose();
|
|
||||||
}, [pendingProvider, onSaveAndSync, onClose]);
|
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel", { defaultValue: "取消" })}
|
|
||||||
</Button>
|
|
||||||
{isEditMode && onSaveAndSync ? (
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveAndSyncClick}
|
|
||||||
disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
|
||||||
{t("universalProvider.saveAndSync", { defaultValue: "保存并同步" })}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}
|
|
||||||
>
|
|
||||||
{t("common.add", { defaultValue: "添加" })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={
|
|
||||||
isEditMode
|
|
||||||
? t("universalProvider.edit", { defaultValue: "编辑统一供应商" })
|
|
||||||
: t("universalProvider.add", { defaultValue: "添加统一供应商" })
|
|
||||||
}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={footer}
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 预设选择(仅新建模式) */}
|
|
||||||
{!isEditMode && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>
|
|
||||||
{t("universalProvider.selectPreset", {
|
|
||||||
defaultValue: "选择预设类型",
|
|
||||||
})}
|
|
||||||
</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{universalProviderPresets.map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset.providerType}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePresetSelect(preset)}
|
|
||||||
className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
selectedPreset?.providerType === preset.providerType
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-accent text-muted-foreground hover:bg-accent/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ProviderIcon
|
|
||||||
icon={preset.icon}
|
|
||||||
name={preset.name}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
{preset.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{selectedPreset?.description && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{selectedPreset.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 基本信息 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">
|
|
||||||
{t("universalProvider.name", { defaultValue: "名称" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder={t("universalProvider.namePlaceholder", {
|
|
||||||
defaultValue: "例如:我的 NewAPI",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="baseUrl">
|
|
||||||
{t("universalProvider.baseUrl", { defaultValue: "API 地址" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="baseUrl"
|
|
||||||
value={baseUrl}
|
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
|
||||||
placeholder="https://api.example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="apiKey">
|
|
||||||
{t("universalProvider.apiKey", { defaultValue: "API Key" })}
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="apiKey"
|
|
||||||
type={showApiKey ? "text" : "password"}
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
placeholder="sk-..."
|
|
||||||
className="pr-10"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-0 top-0 h-full px-3"
|
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
|
||||||
>
|
|
||||||
{showApiKey ? (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="websiteUrl">
|
|
||||||
{t("universalProvider.websiteUrl", { defaultValue: "官网地址" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="websiteUrl"
|
|
||||||
value={websiteUrl}
|
|
||||||
onChange={(e) => setWebsiteUrl(e.target.value)}
|
|
||||||
placeholder={t("universalProvider.websiteUrlPlaceholder", {
|
|
||||||
defaultValue: "https://example.com(可选,用于在列表中显示)",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="notes">
|
|
||||||
{t("universalProvider.notes", { defaultValue: "备注" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="notes"
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
placeholder={t("universalProvider.notesPlaceholder", {
|
|
||||||
defaultValue: "可选:添加备注信息",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 应用启用 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>
|
|
||||||
{t("universalProvider.enabledApps", { defaultValue: "启用的应用" })}
|
|
||||||
</Label>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon icon="claude" name="Claude" size={20} />
|
|
||||||
<span className="font-medium">Claude Code</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={claudeEnabled}
|
|
||||||
onCheckedChange={setClaudeEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon icon="openai" name="Codex" size={20} />
|
|
||||||
<span className="font-medium">OpenAI Codex</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={codexEnabled}
|
|
||||||
onCheckedChange={setCodexEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProviderIcon icon="gemini" name="Gemini" size={20} />
|
|
||||||
<span className="font-medium">Gemini CLI</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={geminiEnabled}
|
|
||||||
onCheckedChange={setGeminiEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 模型配置 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Label>
|
|
||||||
{t("universalProvider.modelConfig", { defaultValue: "模型配置" })}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{/* Claude 模型 */}
|
|
||||||
{claudeEnabled && (
|
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center gap-2 font-medium">
|
|
||||||
<ProviderIcon icon="claude" name="Claude" size={16} />
|
|
||||||
Claude
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">
|
|
||||||
{t("universalProvider.model", { defaultValue: "主模型" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={models.claude?.model || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateModel("claude", "model", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="claude-sonnet-4-20250514"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Haiku</Label>
|
|
||||||
<Input
|
|
||||||
value={models.claude?.haikuModel || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateModel("claude", "haikuModel", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="claude-haiku-4-20250514"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Sonnet</Label>
|
|
||||||
<Input
|
|
||||||
value={models.claude?.sonnetModel || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateModel("claude", "sonnetModel", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="claude-sonnet-4-20250514"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Opus</Label>
|
|
||||||
<Input
|
|
||||||
value={models.claude?.opusModel || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateModel("claude", "opusModel", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="claude-sonnet-4-20250514"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Codex 模型 */}
|
|
||||||
{codexEnabled && (
|
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center gap-2 font-medium">
|
|
||||||
<ProviderIcon icon="openai" name="Codex" size={16} />
|
|
||||||
Codex
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">
|
|
||||||
{t("universalProvider.model", { defaultValue: "模型" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={models.codex?.model || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateModel("codex", "model", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="gpt-4o"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Reasoning Effort</Label>
|
|
||||||
<Input
|
|
||||||
value={models.codex?.reasoningEffort || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateModel("codex", "reasoningEffort", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="high"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gemini 模型 */}
|
|
||||||
{geminiEnabled && (
|
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
|
||||||
<div className="flex items-center gap-2 font-medium">
|
|
||||||
<ProviderIcon icon="gemini" name="Gemini" size={16} />
|
|
||||||
Gemini
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">
|
|
||||||
{t("universalProvider.model", { defaultValue: "模型" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={models.gemini?.model || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateModel("gemini", "model", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="gemini-2.5-pro"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 配置 JSON 预览 */}
|
|
||||||
{isEditMode && (claudeEnabled || codexEnabled || geminiEnabled) && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Label>
|
|
||||||
{t("universalProvider.configJsonPreview", {
|
|
||||||
defaultValue: "配置 JSON 预览",
|
|
||||||
})}
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("universalProvider.configJsonPreviewHint", {
|
|
||||||
defaultValue:
|
|
||||||
"以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Claude JSON */}
|
|
||||||
{claudeConfigJson && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<ProviderIcon icon="claude" name="Claude" size={16} />
|
|
||||||
Claude
|
|
||||||
</div>
|
|
||||||
<JsonEditor
|
|
||||||
value={JSON.stringify(claudeConfigJson, null, 2)}
|
|
||||||
onChange={() => {}}
|
|
||||||
height={180}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Codex JSON */}
|
|
||||||
{codexConfigJson && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<ProviderIcon icon="openai" name="Codex" size={16} />
|
|
||||||
Codex
|
|
||||||
</div>
|
|
||||||
<JsonEditor
|
|
||||||
value={JSON.stringify(codexConfigJson, null, 2)}
|
|
||||||
onChange={() => {}}
|
|
||||||
height={280}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gemini JSON */}
|
|
||||||
{geminiConfigJson && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<ProviderIcon icon="gemini" name="Gemini" size={16} />
|
|
||||||
Gemini
|
|
||||||
</div>
|
|
||||||
<JsonEditor
|
|
||||||
value={JSON.stringify(geminiConfigJson, null, 2)}
|
|
||||||
onChange={() => {}}
|
|
||||||
height={140}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 保存并同步确认弹窗 */}
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={syncConfirmOpen}
|
|
||||||
title={t("universalProvider.syncConfirmTitle", {
|
|
||||||
defaultValue: "同步统一供应商",
|
|
||||||
})}
|
|
||||||
message={t("universalProvider.syncConfirmDescription", {
|
|
||||||
defaultValue: `同步 "${name}" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?`,
|
|
||||||
name: name,
|
|
||||||
})}
|
|
||||||
confirmText={t("universalProvider.saveAndSync", {
|
|
||||||
defaultValue: "保存并同步",
|
|
||||||
})}
|
|
||||||
onConfirm={confirmSaveAndSync}
|
|
||||||
onCancel={() => {
|
|
||||||
setSyncConfirmOpen(false);
|
|
||||||
setPendingProvider(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Layers } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
||||||
import { UniversalProviderCard } from "./UniversalProviderCard";
|
|
||||||
import { UniversalProviderFormModal } from "./UniversalProviderFormModal";
|
|
||||||
import { universalProvidersApi } from "@/lib/api";
|
|
||||||
import type { UniversalProvider, UniversalProvidersMap } from "@/types";
|
|
||||||
|
|
||||||
export function UniversalProviderPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
const [providers, setProviders] = useState<UniversalProvidersMap>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
||||||
const [editingProvider, setEditingProvider] =
|
|
||||||
useState<UniversalProvider | null>(null);
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
|
||||||
open: boolean;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}>({ open: false, id: "", name: "" });
|
|
||||||
const [syncConfirm, setSyncConfirm] = useState<{
|
|
||||||
open: boolean;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}>({ open: false, id: "", name: "" });
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
const loadProviders = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await universalProvidersApi.getAll();
|
|
||||||
setProviders(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load universal providers:", error);
|
|
||||||
toast.error(
|
|
||||||
t("universalProvider.loadError", {
|
|
||||||
defaultValue: "加载统一供应商失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadProviders();
|
|
||||||
}, [loadProviders]);
|
|
||||||
|
|
||||||
// 添加/编辑供应商
|
|
||||||
const handleSave = useCallback(
|
|
||||||
async (provider: UniversalProvider) => {
|
|
||||||
try {
|
|
||||||
await universalProvidersApi.upsert(provider);
|
|
||||||
|
|
||||||
// 新建模式下自动同步到各应用
|
|
||||||
if (!editingProvider) {
|
|
||||||
await universalProvidersApi.sync(provider.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
editingProvider
|
|
||||||
? t("universalProvider.updated", {
|
|
||||||
defaultValue: "统一供应商已更新",
|
|
||||||
})
|
|
||||||
: t("universalProvider.addedAndSynced", {
|
|
||||||
defaultValue: "统一供应商已添加并同步",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
loadProviders();
|
|
||||||
setEditingProvider(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save universal provider:", error);
|
|
||||||
toast.error(
|
|
||||||
t("universalProvider.saveError", {
|
|
||||||
defaultValue: "保存统一供应商失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[editingProvider, loadProviders, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 保存并同步供应商
|
|
||||||
const handleSaveAndSync = useCallback(
|
|
||||||
async (provider: UniversalProvider) => {
|
|
||||||
try {
|
|
||||||
await universalProvidersApi.upsert(provider);
|
|
||||||
await universalProvidersApi.sync(provider.id);
|
|
||||||
toast.success(
|
|
||||||
t("universalProvider.savedAndSynced", {
|
|
||||||
defaultValue: "已保存并同步到所有应用",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
loadProviders();
|
|
||||||
setEditingProvider(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save and sync universal provider:", error);
|
|
||||||
toast.error(
|
|
||||||
t("universalProvider.saveAndSyncError", {
|
|
||||||
defaultValue: "保存并同步失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[loadProviders, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 删除供应商
|
|
||||||
const handleDelete = useCallback(async () => {
|
|
||||||
if (!deleteConfirm.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await universalProvidersApi.delete(deleteConfirm.id);
|
|
||||||
toast.success(
|
|
||||||
t("universalProvider.deleted", { defaultValue: "统一供应商已删除" }),
|
|
||||||
);
|
|
||||||
loadProviders();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete universal provider:", error);
|
|
||||||
toast.error(
|
|
||||||
t("universalProvider.deleteError", {
|
|
||||||
defaultValue: "删除统一供应商失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setDeleteConfirm({ open: false, id: "", name: "" });
|
|
||||||
}
|
|
||||||
}, [deleteConfirm.id, loadProviders, t]);
|
|
||||||
|
|
||||||
// 同步供应商
|
|
||||||
const handleSync = useCallback(async () => {
|
|
||||||
if (!syncConfirm.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await universalProvidersApi.sync(syncConfirm.id);
|
|
||||||
toast.success(
|
|
||||||
t("universalProvider.synced", { defaultValue: "已同步到所有应用" }),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to sync universal provider:", error);
|
|
||||||
toast.error(
|
|
||||||
t("universalProvider.syncError", {
|
|
||||||
defaultValue: "同步统一供应商失败",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setSyncConfirm({ open: false, id: "", name: "" });
|
|
||||||
}
|
|
||||||
}, [syncConfirm.id, t]);
|
|
||||||
|
|
||||||
// 打开同步确认
|
|
||||||
const handleSyncClick = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
const provider = providers[id];
|
|
||||||
setSyncConfirm({
|
|
||||||
open: true,
|
|
||||||
id,
|
|
||||||
name: provider?.name || id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[providers],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 打开编辑
|
|
||||||
const handleEdit = useCallback((provider: UniversalProvider) => {
|
|
||||||
setEditingProvider(provider);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 打开删除确认
|
|
||||||
const handleDeleteClick = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
const provider = providers[id];
|
|
||||||
setDeleteConfirm({
|
|
||||||
open: true,
|
|
||||||
id,
|
|
||||||
name: provider?.name || id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[providers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const providerList = Object.values(providers);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Layers className="h-5 w-5 text-primary" />
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{t("universalProvider.title", { defaultValue: "统一供应商" })}
|
|
||||||
</h2>
|
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
||||||
{providerList.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("universalProvider.description", {
|
|
||||||
defaultValue:
|
|
||||||
"统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 供应商列表 */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
) : providerList.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center">
|
|
||||||
<Layers className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("universalProvider.empty", {
|
|
||||||
defaultValue: "还没有统一供应商",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
||||||
{t("universalProvider.emptyHint", {
|
|
||||||
defaultValue: "点击下方「添加统一供应商」按钮创建一个",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{providerList.map((provider) => (
|
|
||||||
<UniversalProviderCard
|
|
||||||
key={provider.id}
|
|
||||||
provider={provider}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDeleteClick}
|
|
||||||
onSync={handleSyncClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 表单模态框 */}
|
|
||||||
<UniversalProviderFormModal
|
|
||||||
isOpen={isFormOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setEditingProvider(null);
|
|
||||||
}}
|
|
||||||
onSave={handleSave}
|
|
||||||
onSaveAndSync={handleSaveAndSync}
|
|
||||||
editingProvider={editingProvider}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 删除确认对话框 */}
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={deleteConfirm.open}
|
|
||||||
title={t("universalProvider.deleteConfirmTitle", {
|
|
||||||
defaultValue: "删除统一供应商",
|
|
||||||
})}
|
|
||||||
message={t("universalProvider.deleteConfirmDescription", {
|
|
||||||
defaultValue: `确定要删除 "${deleteConfirm.name}" 吗?这将同时删除它在各应用中生成的供应商配置。`,
|
|
||||||
name: deleteConfirm.name,
|
|
||||||
})}
|
|
||||||
confirmText={t("common.delete", { defaultValue: "删除" })}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
onCancel={() => setDeleteConfirm({ open: false, id: "", name: "" })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 同步确认对话框 */}
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={syncConfirm.open}
|
|
||||||
title={t("universalProvider.syncConfirmTitle", {
|
|
||||||
defaultValue: "同步统一供应商",
|
|
||||||
})}
|
|
||||||
message={t("universalProvider.syncConfirmDescription", {
|
|
||||||
defaultValue: `同步 "${syncConfirm.name}" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?`,
|
|
||||||
name: syncConfirm.name,
|
|
||||||
})}
|
|
||||||
confirmText={t("universalProvider.syncConfirm", {
|
|
||||||
defaultValue: "同步",
|
|
||||||
})}
|
|
||||||
onConfirm={handleSync}
|
|
||||||
onCancel={() => setSyncConfirm({ open: false, id: "", name: "" })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { UniversalProviderPanel } from "./UniversalProviderPanel";
|
|
||||||
export { UniversalProviderCard } from "./UniversalProviderCard";
|
|
||||||
export { UniversalProviderFormModal } from "./UniversalProviderFormModal";
|
|
||||||
@@ -391,22 +391,4 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
icon: "openrouter",
|
icon: "openrouter",
|
||||||
iconColor: "#6566F1",
|
iconColor: "#6566F1",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Xiaomi MiMo",
|
|
||||||
websiteUrl: "https://platform.xiaomimimo.com",
|
|
||||||
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
|
|
||||||
settingsConfig: {
|
|
||||||
env: {
|
|
||||||
ANTHROPIC_BASE_URL: "https://api.xiaomimimo.com/anthropic",
|
|
||||||
ANTHROPIC_AUTH_TOKEN: "",
|
|
||||||
ANTHROPIC_MODEL: "mimo-v2-flash",
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "mimo-v2-flash",
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "mimo-v2-flash",
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "mimo-v2-flash",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
category: "cn_official",
|
|
||||||
icon: "xiaomimimo",
|
|
||||||
iconColor: "#000000",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
/**
|
|
||||||
* 统一供应商(Universal Provider)预设配置
|
|
||||||
*
|
|
||||||
* 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
|
|
||||||
* 适用于 NewAPI 等支持多种协议的 API 网关。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
UniversalProvider,
|
|
||||||
UniversalProviderApps,
|
|
||||||
UniversalProviderModels,
|
|
||||||
} from "@/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一供应商预设接口
|
|
||||||
*/
|
|
||||||
export interface UniversalProviderPreset {
|
|
||||||
/** 预设名称 */
|
|
||||||
name: string;
|
|
||||||
/** 供应商类型标识 */
|
|
||||||
providerType: string;
|
|
||||||
/** 默认启用的应用 */
|
|
||||||
defaultApps: UniversalProviderApps;
|
|
||||||
/** 默认模型配置 */
|
|
||||||
defaultModels: UniversalProviderModels;
|
|
||||||
/** 网站链接 */
|
|
||||||
websiteUrl?: string;
|
|
||||||
/** 图标名称 */
|
|
||||||
icon?: string;
|
|
||||||
/** 图标颜色 */
|
|
||||||
iconColor?: string;
|
|
||||||
/** 描述 */
|
|
||||||
description?: string;
|
|
||||||
/** 是否为自定义模板(允许用户完全自定义) */
|
|
||||||
isCustomTemplate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NewAPI 默认模型配置
|
|
||||||
*/
|
|
||||||
const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-20250514",
|
|
||||||
haikuModel: "claude-haiku-4-20250514",
|
|
||||||
sonnetModel: "claude-sonnet-4-20250514",
|
|
||||||
opusModel: "claude-sonnet-4-20250514",
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
model: "gpt-4o",
|
|
||||||
reasoningEffort: "high",
|
|
||||||
},
|
|
||||||
gemini: {
|
|
||||||
model: "gemini-2.5-pro",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一供应商预设列表
|
|
||||||
*/
|
|
||||||
export const universalProviderPresets: UniversalProviderPreset[] = [
|
|
||||||
{
|
|
||||||
name: "NewAPI",
|
|
||||||
providerType: "newapi",
|
|
||||||
defaultApps: {
|
|
||||||
claude: true,
|
|
||||||
codex: true,
|
|
||||||
gemini: true,
|
|
||||||
},
|
|
||||||
defaultModels: NEWAPI_DEFAULT_MODELS,
|
|
||||||
websiteUrl: "https://www.newapi.pro",
|
|
||||||
icon: "newapi",
|
|
||||||
iconColor: "#00A67E",
|
|
||||||
description:
|
|
||||||
"NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "自定义网关",
|
|
||||||
providerType: "custom_gateway",
|
|
||||||
defaultApps: {
|
|
||||||
claude: true,
|
|
||||||
codex: true,
|
|
||||||
gemini: true,
|
|
||||||
},
|
|
||||||
defaultModels: NEWAPI_DEFAULT_MODELS,
|
|
||||||
icon: "openai",
|
|
||||||
iconColor: "#6366F1",
|
|
||||||
description: "自定义配置的 API 网关",
|
|
||||||
isCustomTemplate: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据预设创建统一供应商
|
|
||||||
*/
|
|
||||||
export function createUniversalProviderFromPreset(
|
|
||||||
preset: UniversalProviderPreset,
|
|
||||||
id: string,
|
|
||||||
baseUrl: string,
|
|
||||||
apiKey: string,
|
|
||||||
customName?: string,
|
|
||||||
): UniversalProvider {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: customName || preset.name,
|
|
||||||
providerType: preset.providerType,
|
|
||||||
apps: { ...preset.defaultApps },
|
|
||||||
baseUrl,
|
|
||||||
apiKey,
|
|
||||||
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
|
|
||||||
websiteUrl: preset.websiteUrl,
|
|
||||||
icon: preset.icon,
|
|
||||||
iconColor: preset.iconColor,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取预设的显示名称(用于 UI)
|
|
||||||
*/
|
|
||||||
export function getPresetDisplayName(preset: UniversalProviderPreset): string {
|
|
||||||
return preset.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据类型查找预设
|
|
||||||
*/
|
|
||||||
export function findPresetByType(
|
|
||||||
providerType: string,
|
|
||||||
): UniversalProviderPreset | undefined {
|
|
||||||
return universalProviderPresets.find((p) => p.providerType === providerType);
|
|
||||||
}
|
|
||||||
@@ -66,8 +66,6 @@
|
|||||||
"exitEditMode": "Exit Edit Mode"
|
"exitEditMode": "Exit Edit Mode"
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"tabProvider": "Provider",
|
|
||||||
"tabUniversal": "Universal",
|
|
||||||
"noProviders": "No providers added yet",
|
"noProviders": "No providers added yet",
|
||||||
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
|
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
|
||||||
"currentlyUsing": "Currently Using",
|
"currentlyUsing": "Currently Using",
|
||||||
@@ -342,10 +340,6 @@
|
|||||||
"visitWebsite": "Visit {{url}}",
|
"visitWebsite": "Visit {{url}}",
|
||||||
"anthropicModel": "Main Model",
|
"anthropicModel": "Main Model",
|
||||||
"anthropicSmallFastModel": "Fast Model",
|
"anthropicSmallFastModel": "Fast Model",
|
||||||
"anthropicReasoningModel": "Reasoning Model (Thinking)",
|
|
||||||
"reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514",
|
|
||||||
"openrouterCompatMode": "OpenRouter Compatibility Mode",
|
|
||||||
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
|
|
||||||
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||||
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
||||||
"anthropicDefaultOpusModel": "Default Opus Model",
|
"anthropicDefaultOpusModel": "Default Opus Model",
|
||||||
@@ -1063,9 +1057,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switchFailed": "Switch failed: {{error}}",
|
"switchFailed": "Switch failed: {{error}}",
|
||||||
"failover": {
|
|
||||||
"proxyRequired": "Proxy service must be started to configure failover"
|
|
||||||
},
|
|
||||||
"failoverQueue": {
|
"failoverQueue": {
|
||||||
"title": "Failover Queue",
|
"title": "Failover Queue",
|
||||||
"description": "Manage failover order for each app's providers",
|
"description": "Manage failover order for each app's providers",
|
||||||
@@ -1137,49 +1128,5 @@
|
|||||||
"streamingFirstByte": "Streaming First Byte Timeout",
|
"streamingFirstByte": "Streaming First Byte Timeout",
|
||||||
"streamingIdle": "Streaming Idle Timeout",
|
"streamingIdle": "Streaming Idle Timeout",
|
||||||
"nonStreaming": "Non-Streaming Timeout"
|
"nonStreaming": "Non-Streaming Timeout"
|
||||||
},
|
|
||||||
"universalProvider": {
|
|
||||||
"title": "Universal Provider",
|
|
||||||
"description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.",
|
|
||||||
"add": "Add Universal Provider",
|
|
||||||
"edit": "Edit Universal Provider",
|
|
||||||
"empty": "No universal providers yet",
|
|
||||||
"emptyHint": "Click the \"Add Universal Provider\" button below to create one",
|
|
||||||
"selectPreset": "Select Preset Type",
|
|
||||||
"name": "Name",
|
|
||||||
"namePlaceholder": "e.g., My NewAPI",
|
|
||||||
"baseUrl": "API URL",
|
|
||||||
"apiKey": "API Key",
|
|
||||||
"websiteUrl": "Website URL",
|
|
||||||
"websiteUrlPlaceholder": "https://example.com (optional, displayed in the list)",
|
|
||||||
"notes": "Notes",
|
|
||||||
"notesPlaceholder": "Optional: Add notes",
|
|
||||||
"enabledApps": "Enabled Apps",
|
|
||||||
"modelConfig": "Model Configuration",
|
|
||||||
"model": "Model",
|
|
||||||
"sync": "Sync to Apps",
|
|
||||||
"synced": "Synced to all apps",
|
|
||||||
"syncError": "Sync failed",
|
|
||||||
"noAppsEnabled": "No apps enabled",
|
|
||||||
"added": "Universal provider added",
|
|
||||||
"addedAndSynced": "Universal provider added and synced",
|
|
||||||
"updated": "Universal provider updated",
|
|
||||||
"deleted": "Universal provider deleted",
|
|
||||||
"addSuccess": "Universal provider added successfully",
|
|
||||||
"addFailed": "Failed to add universal provider",
|
|
||||||
"manage": "Manage",
|
|
||||||
"loadError": "Failed to load universal providers",
|
|
||||||
"saveError": "Failed to save universal provider",
|
|
||||||
"deleteError": "Failed to delete universal provider",
|
|
||||||
"deleteConfirmTitle": "Delete Universal Provider",
|
|
||||||
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This will also delete its generated provider configurations in each app.",
|
|
||||||
"syncConfirmTitle": "Sync Universal Provider",
|
|
||||||
"syncConfirmDescription": "Syncing \"{{name}}\" will overwrite the associated provider configurations in Claude, Codex, and Gemini. Do you want to continue?",
|
|
||||||
"syncConfirm": "Sync",
|
|
||||||
"saveAndSync": "Save & Sync",
|
|
||||||
"savedAndSynced": "Saved and synced to all apps",
|
|
||||||
"saveAndSyncError": "Failed to save and sync",
|
|
||||||
"configJsonPreview": "Config JSON Preview",
|
|
||||||
"configJsonPreviewHint": "The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,6 @@
|
|||||||
"exitEditMode": "編集モードを終了"
|
"exitEditMode": "編集モードを終了"
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"tabProvider": "プロバイダー",
|
|
||||||
"tabUniversal": "統一プロバイダー",
|
|
||||||
"noProviders": "まだプロバイダーがありません",
|
"noProviders": "まだプロバイダーがありません",
|
||||||
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
|
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
|
||||||
"currentlyUsing": "現在使用中",
|
"currentlyUsing": "現在使用中",
|
||||||
@@ -342,10 +340,6 @@
|
|||||||
"visitWebsite": "{{url}} を開く",
|
"visitWebsite": "{{url}} を開く",
|
||||||
"anthropicModel": "メインモデル",
|
"anthropicModel": "メインモデル",
|
||||||
"anthropicSmallFastModel": "高速モデル",
|
"anthropicSmallFastModel": "高速モデル",
|
||||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
|
||||||
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
|
|
||||||
"openrouterCompatMode": "OpenRouter 互換モード",
|
|
||||||
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
|
|
||||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||||
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
||||||
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
||||||
@@ -1063,9 +1057,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switchFailed": "切り替えに失敗しました: {{error}}",
|
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||||
"failover": {
|
|
||||||
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります"
|
|
||||||
},
|
|
||||||
"failoverQueue": {
|
"failoverQueue": {
|
||||||
"title": "フェイルオーバーキュー",
|
"title": "フェイルオーバーキュー",
|
||||||
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
|
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
|
||||||
@@ -1137,48 +1128,5 @@
|
|||||||
"streamingFirstByte": "ストリーミング初回バイトタイムアウト",
|
"streamingFirstByte": "ストリーミング初回バイトタイムアウト",
|
||||||
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
||||||
"nonStreaming": "非ストリーミングタイムアウト"
|
"nonStreaming": "非ストリーミングタイムアウト"
|
||||||
},
|
|
||||||
"universalProvider": {
|
|
||||||
"title": "統合プロバイダー",
|
|
||||||
"description": "統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。",
|
|
||||||
"add": "統合プロバイダーを追加",
|
|
||||||
"edit": "統合プロバイダーを編集",
|
|
||||||
"empty": "統合プロバイダーがありません",
|
|
||||||
"emptyHint": "下の「統合プロバイダーを追加」ボタンをクリックして作成してください",
|
|
||||||
"selectPreset": "プリセットタイプを選択",
|
|
||||||
"name": "名前",
|
|
||||||
"namePlaceholder": "例:私の NewAPI",
|
|
||||||
"baseUrl": "API URL",
|
|
||||||
"apiKey": "API キー",
|
|
||||||
"websiteUrl": "ウェブサイト URL",
|
|
||||||
"websiteUrlPlaceholder": "https://example.com(オプション、リストに表示されます)",
|
|
||||||
"notes": "メモ",
|
|
||||||
"notesPlaceholder": "オプション:メモを追加",
|
|
||||||
"enabledApps": "有効なアプリ",
|
|
||||||
"modelConfig": "モデル設定",
|
|
||||||
"model": "モデル",
|
|
||||||
"sync": "アプリに同期",
|
|
||||||
"synced": "すべてのアプリに同期しました",
|
|
||||||
"syncError": "同期に失敗しました",
|
|
||||||
"noAppsEnabled": "有効なアプリがありません",
|
|
||||||
"added": "統合プロバイダーを追加しました",
|
|
||||||
"updated": "統合プロバイダーを更新しました",
|
|
||||||
"deleted": "統合プロバイダーを削除しました",
|
|
||||||
"addSuccess": "統合プロバイダーを追加しました",
|
|
||||||
"addFailed": "統合プロバイダーの追加に失敗しました",
|
|
||||||
"manage": "管理",
|
|
||||||
"loadError": "統合プロバイダーの読み込みに失敗しました",
|
|
||||||
"saveError": "統合プロバイダーの保存に失敗しました",
|
|
||||||
"deleteError": "統合プロバイダーの削除に失敗しました",
|
|
||||||
"deleteConfirmTitle": "統合プロバイダーを削除",
|
|
||||||
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?各アプリで生成されたプロバイダー設定も削除されます。",
|
|
||||||
"syncConfirmTitle": "統合プロバイダーを同期",
|
|
||||||
"syncConfirmDescription": "「{{name}}」を同期すると、Claude、Codex、Gemini の関連プロバイダー設定が上書きされます。続行しますか?",
|
|
||||||
"syncConfirm": "同期",
|
|
||||||
"saveAndSync": "保存して同期",
|
|
||||||
"savedAndSynced": "すべてのアプリに保存・同期されました",
|
|
||||||
"saveAndSyncError": "保存と同期に失敗しました",
|
|
||||||
"configJsonPreview": "設定 JSON プレビュー",
|
|
||||||
"configJsonPreviewHint": "以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,6 @@
|
|||||||
"exitEditMode": "退出编辑模式"
|
"exitEditMode": "退出编辑模式"
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"tabProvider": "供应商",
|
|
||||||
"tabUniversal": "统一供应商",
|
|
||||||
"noProviders": "还没有添加任何供应商",
|
"noProviders": "还没有添加任何供应商",
|
||||||
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
|
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
|
||||||
"currentlyUsing": "当前使用",
|
"currentlyUsing": "当前使用",
|
||||||
@@ -342,10 +340,6 @@
|
|||||||
"visitWebsite": "访问 {{url}}",
|
"visitWebsite": "访问 {{url}}",
|
||||||
"anthropicModel": "主模型",
|
"anthropicModel": "主模型",
|
||||||
"anthropicSmallFastModel": "快速模型",
|
"anthropicSmallFastModel": "快速模型",
|
||||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
|
||||||
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
|
|
||||||
"openrouterCompatMode": "OpenRouter 兼容模式",
|
|
||||||
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
|
||||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||||
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
||||||
"anthropicDefaultOpusModel": "Opus 默认模型",
|
"anthropicDefaultOpusModel": "Opus 默认模型",
|
||||||
@@ -1063,9 +1057,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switchFailed": "切换失败: {{error}}",
|
"switchFailed": "切换失败: {{error}}",
|
||||||
"failover": {
|
|
||||||
"proxyRequired": "需要先启动代理服务才能配置故障转移"
|
|
||||||
},
|
|
||||||
"failoverQueue": {
|
"failoverQueue": {
|
||||||
"title": "故障转移队列",
|
"title": "故障转移队列",
|
||||||
"description": "管理各应用的供应商故障转移顺序",
|
"description": "管理各应用的供应商故障转移顺序",
|
||||||
@@ -1137,49 +1128,5 @@
|
|||||||
"streamingFirstByte": "流式首字节超时",
|
"streamingFirstByte": "流式首字节超时",
|
||||||
"streamingIdle": "流式静默超时",
|
"streamingIdle": "流式静默超时",
|
||||||
"nonStreaming": "非流式超时"
|
"nonStreaming": "非流式超时"
|
||||||
},
|
|
||||||
"universalProvider": {
|
|
||||||
"title": "统一供应商",
|
|
||||||
"description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",
|
|
||||||
"add": "添加统一供应商",
|
|
||||||
"edit": "编辑统一供应商",
|
|
||||||
"empty": "还没有统一供应商",
|
|
||||||
"emptyHint": "点击下方「添加统一供应商」按钮创建一个",
|
|
||||||
"selectPreset": "选择预设类型",
|
|
||||||
"name": "名称",
|
|
||||||
"namePlaceholder": "例如:我的 NewAPI",
|
|
||||||
"baseUrl": "API 地址",
|
|
||||||
"apiKey": "API Key",
|
|
||||||
"websiteUrl": "官网地址",
|
|
||||||
"websiteUrlPlaceholder": "https://example.com(可选,用于在列表中显示)",
|
|
||||||
"notes": "备注",
|
|
||||||
"notesPlaceholder": "可选:添加备注信息",
|
|
||||||
"enabledApps": "启用的应用",
|
|
||||||
"modelConfig": "模型配置",
|
|
||||||
"model": "模型",
|
|
||||||
"sync": "同步到应用",
|
|
||||||
"synced": "已同步到所有应用",
|
|
||||||
"syncError": "同步失败",
|
|
||||||
"noAppsEnabled": "未启用任何应用",
|
|
||||||
"added": "统一供应商已添加",
|
|
||||||
"addedAndSynced": "统一供应商已添加并同步",
|
|
||||||
"updated": "统一供应商已更新",
|
|
||||||
"deleted": "统一供应商已删除",
|
|
||||||
"addSuccess": "统一供应商添加成功",
|
|
||||||
"addFailed": "统一供应商添加失败",
|
|
||||||
"manage": "管理",
|
|
||||||
"loadError": "加载统一供应商失败",
|
|
||||||
"saveError": "保存统一供应商失败",
|
|
||||||
"deleteError": "删除统一供应商失败",
|
|
||||||
"deleteConfirmTitle": "删除统一供应商",
|
|
||||||
"deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?这将同时删除它在各应用中生成的供应商配置。",
|
|
||||||
"syncConfirmTitle": "同步统一供应商",
|
|
||||||
"syncConfirmDescription": "同步 \"{{name}}\" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?",
|
|
||||||
"syncConfirm": "同步",
|
|
||||||
"saveAndSync": "保存并同步",
|
|
||||||
"savedAndSynced": "已保存并同步到所有应用",
|
|
||||||
"saveAndSyncError": "保存并同步失败",
|
|
||||||
"configJsonPreview": "配置 JSON 预览",
|
|
||||||
"configJsonPreviewHint": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,13 +198,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
|||||||
keywords: ["mistral"],
|
keywords: ["mistral"],
|
||||||
defaultColor: "#FF7000",
|
defaultColor: "#FF7000",
|
||||||
},
|
},
|
||||||
newapi: {
|
|
||||||
name: "newapi",
|
|
||||||
displayName: "newapi",
|
|
||||||
category: "other",
|
|
||||||
keywords: [],
|
|
||||||
defaultColor: "currentColor",
|
|
||||||
},
|
|
||||||
notion: {
|
notion: {
|
||||||
name: "notion",
|
name: "notion",
|
||||||
displayName: "notion",
|
displayName: "notion",
|
||||||
@@ -338,13 +331,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
|||||||
keywords: ["aihubmix", "hub", "mix", "aggregator"],
|
keywords: ["aihubmix", "hub", "mix", "aggregator"],
|
||||||
defaultColor: "#006FFB",
|
defaultColor: "#006FFB",
|
||||||
},
|
},
|
||||||
xiaomimimo: {
|
|
||||||
name: "xiaomimimo",
|
|
||||||
displayName: "Xiaomi MiMo",
|
|
||||||
category: "ai-provider",
|
|
||||||
keywords: ["xiaomimimo", "xiaomi", "mimo"],
|
|
||||||
defaultColor: "#000000",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>NewAPI</title><path d="M23.078 16.34c-.506 1.323-1.198 2.519-2.117 3.562-2.378 2.696-5.374 4.057-8.971 4.098a.037.037 0 01-.024-.01.041.041 0 01-.013-.023.041.041 0 01.003-.025.037.037 0 01.019-.019c1.886-.779 3.454-1.973 4.625-3.639a10.148 10.148 0 001.626-3.677c.217-.98.33-1.955.282-2.942-.048-1.018-.152-1.601-.484-2.565-.386-1.12-.915-2.16-1.627-3.089-.883-1.154-1.876-1.87-2.9-2.779-.995-.88-2.19-2.623-1.059-3.754.384-.384.997-.59 1.838-.621 2.478-.09 5.011 1.636 6.597 3.453.75.86 1.38 1.798 1.865 2.837.486 1.041.814 2.122.978 3.246.133.915.117 1.441.092 2.365a10.82 10.82 0 01-.73 3.582z" fill="url(#lobe-icons-new-api-fill-0)"></path><path d="M11.86.01a.041.041 0 01.009.049.038.038 0 01-.018.018C9.964.856 8.396 2.05 7.225 3.716a10.148 10.148 0 00-1.626 3.678c-.217.979-.33 1.955-.283 2.941.049 1.018.154 1.601.486 2.565.385 1.12.914 2.16 1.626 3.088.883 1.154 1.876 1.872 2.9 2.78.995.88 2.19 2.622 1.059 3.753-.385.385-.997.591-1.838.622-2.478.089-5.011-1.636-6.597-3.454-.75-.86-1.38-1.797-1.865-2.837a11.591 11.591 0 01-.978-3.246c-.133-.914-.117-1.44-.091-2.364.034-1.225.284-2.416.73-3.582.504-1.323 1.197-2.52 2.116-3.562C5.241 1.402 8.238.04 11.835 0c.009 0 .018.004.024.01z" fill="url(#lobe-icons-new-api-fill-1)"></path><path d="M8.721 11.903l2.455-.708.72-2.48a.066.066 0 01.127.002l.58 2.26c.776.437 1.65.755 2.622.956a.05.05 0 01.028.075.05.05 0 01-.024.019l-2.382.709a.163.163 0 00-.109.108l-.72 2.444a.034.034 0 01-.031.027.034.034 0 01-.034-.024l-.713-2.395a.183.183 0 00-.128-.128l-2.39-.705a.084.084 0 01-.044-.13.084.084 0 01.043-.03z" fill="url(#lobe-icons-new-api-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-new-api-fill-0" x1="17.889" x2="17.889" y1=".854" y2="24"><stop stop-color="#F85EAD"></stop><stop offset="1" stop-color="#FD75FD"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-new-api-fill-1" x1="5.936" x2="5.936" y1="0" y2="23.146"><stop offset=".332" stop-color="#11F5EF"></stop><stop offset="1" stop-color="#C738FB"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-new-api-fill-2" x1="11.961" x2="11.961" y1="8.666" y2="15.315"><stop offset=".332" stop-color="#11F5EF"></stop><stop offset="1" stop-color="#C738FB"></stop></linearGradient></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
@@ -1,5 +1,5 @@
|
|||||||
export type { AppId } from "./types";
|
export type { AppId } from "./types";
|
||||||
export { providersApi, universalProvidersApi } from "./providers";
|
export { providersApi } from "./providers";
|
||||||
export { settingsApi } from "./settings";
|
export { settingsApi } from "./settings";
|
||||||
export { mcpApi } from "./mcp";
|
export { mcpApi } from "./mcp";
|
||||||
export { promptsApi } from "./prompts";
|
export { promptsApi } from "./prompts";
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import type {
|
import type { Provider } from "@/types";
|
||||||
Provider,
|
|
||||||
UniversalProvider,
|
|
||||||
UniversalProvidersMap,
|
|
||||||
} from "@/types";
|
|
||||||
import type { AppId } from "./types";
|
import type { AppId } from "./types";
|
||||||
|
|
||||||
export interface ProviderSortUpdate {
|
export interface ProviderSortUpdate {
|
||||||
@@ -66,44 +62,3 @@ export const providersApi = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 统一供应商(Universal Provider)API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const universalProvidersApi = {
|
|
||||||
/**
|
|
||||||
* 获取所有统一供应商
|
|
||||||
*/
|
|
||||||
async getAll(): Promise<UniversalProvidersMap> {
|
|
||||||
return await invoke("get_universal_providers");
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取单个统一供应商
|
|
||||||
*/
|
|
||||||
async get(id: string): Promise<UniversalProvider | null> {
|
|
||||||
return await invoke("get_universal_provider", { id });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加或更新统一供应商
|
|
||||||
*/
|
|
||||||
async upsert(provider: UniversalProvider): Promise<boolean> {
|
|
||||||
return await invoke("upsert_universal_provider", { provider });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除统一供应商
|
|
||||||
*/
|
|
||||||
async delete(id: string): Promise<boolean> {
|
|
||||||
return await invoke("delete_universal_provider", { id });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 手动同步统一供应商到各应用
|
|
||||||
*/
|
|
||||||
async sync(id: string): Promise<boolean> {
|
|
||||||
return await invoke("sync_universal_provider", { id });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -186,61 +186,3 @@ export interface McpConfigResponse {
|
|||||||
configPath: string;
|
configPath: string;
|
||||||
servers: Record<string, McpServer>;
|
servers: Record<string, McpServer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 统一供应商(Universal Provider)- 跨应用共享配置
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// 统一供应商的应用启用状态
|
|
||||||
export interface UniversalProviderApps {
|
|
||||||
claude: boolean;
|
|
||||||
codex: boolean;
|
|
||||||
gemini: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude 模型配置
|
|
||||||
export interface ClaudeModelConfig {
|
|
||||||
model?: string;
|
|
||||||
haikuModel?: string;
|
|
||||||
sonnetModel?: string;
|
|
||||||
opusModel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex 模型配置
|
|
||||||
export interface CodexModelConfig {
|
|
||||||
model?: string;
|
|
||||||
reasoningEffort?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gemini 模型配置
|
|
||||||
export interface GeminiModelConfig {
|
|
||||||
model?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 各应用的模型配置
|
|
||||||
export interface UniversalProviderModels {
|
|
||||||
claude?: ClaudeModelConfig;
|
|
||||||
codex?: CodexModelConfig;
|
|
||||||
gemini?: GeminiModelConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一供应商(跨应用共享配置)
|
|
||||||
export interface UniversalProvider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
providerType: string; // "newapi" | "custom" 等
|
|
||||||
apps: UniversalProviderApps;
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
models: UniversalProviderModels;
|
|
||||||
websiteUrl?: string;
|
|
||||||
notes?: string;
|
|
||||||
icon?: string;
|
|
||||||
iconColor?: string;
|
|
||||||
meta?: ProviderMeta;
|
|
||||||
createdAt?: number;
|
|
||||||
sortIndex?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一供应商映射(id -> UniversalProvider)
|
|
||||||
export type UniversalProvidersMap = Record<string, UniversalProvider>;
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: ["./src/index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
|
||||||
darkMode: ["selector", "class"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
background: "hsl(var(--background))",
|
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
card: {
|
|
||||||
DEFAULT: "hsl(var(--card))",
|
|
||||||
foreground: "hsl(var(--card-foreground))",
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: "hsl(var(--popover))",
|
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "hsl(var(--primary))",
|
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
border: "hsl(var(--border))",
|
|
||||||
input: "hsl(var(--input))",
|
|
||||||
ring: "hsl(var(--ring))",
|
|
||||||
blue: {
|
|
||||||
400: "#409CFF",
|
|
||||||
500: "#0A84FF",
|
|
||||||
600: "#0060DF",
|
|
||||||
},
|
|
||||||
gray: {
|
|
||||||
50: "#fafafa",
|
|
||||||
100: "#f4f4f5",
|
|
||||||
200: "#e4e4e7",
|
|
||||||
300: "#d4d4d8",
|
|
||||||
400: "#a1a1aa",
|
|
||||||
500: "#71717a",
|
|
||||||
600: "#636366",
|
|
||||||
700: "#48484A",
|
|
||||||
800: "#3A3A3C",
|
|
||||||
900: "#2C2C2E",
|
|
||||||
950: "#1C1C1E",
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
100: "#d1fae5",
|
|
||||||
500: "#10b981",
|
|
||||||
},
|
|
||||||
red: {
|
|
||||||
100: "#fee2e2",
|
|
||||||
500: "#ef4444",
|
|
||||||
},
|
|
||||||
amber: {
|
|
||||||
100: "#fef3c7",
|
|
||||||
500: "#f59e0b",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
|
||||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
|
|
||||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
sm: "0.375rem",
|
|
||||||
md: "0.5rem",
|
|
||||||
lg: "0.75rem",
|
|
||||||
xl: "0.875rem",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
// 使用与之前版本保持一致的系统字体栈
|
|
||||||
sans: [
|
|
||||||
"-apple-system",
|
|
||||||
"BlinkMacSystemFont",
|
|
||||||
'"Segoe UI"',
|
|
||||||
"Roboto",
|
|
||||||
'"Helvetica Neue"',
|
|
||||||
"Arial",
|
|
||||||
"sans-serif",
|
|
||||||
],
|
|
||||||
mono: [
|
|
||||||
"ui-monospace",
|
|
||||||
"SFMono-Regular",
|
|
||||||
'"SF Mono"',
|
|
||||||
"Consolas",
|
|
||||||
'"Liberation Mono"',
|
|
||||||
"Menlo",
|
|
||||||
"monospace",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
"fade-in": "fadeIn 0.5s ease-out",
|
|
||||||
"slide-up": "slideUp 0.5s ease-out",
|
|
||||||
"slide-down": "slideDown 0.3s ease-out",
|
|
||||||
"slide-in-right": "slideInRight 0.3s ease-out",
|
|
||||||
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
fadeIn: {
|
|
||||||
"0%": {
|
|
||||||
opacity: "0",
|
|
||||||
},
|
|
||||||
"100%": {
|
|
||||||
opacity: "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
slideUp: {
|
|
||||||
"0%": {
|
|
||||||
transform: "translateY(20px)",
|
|
||||||
opacity: "0",
|
|
||||||
},
|
|
||||||
"100%": {
|
|
||||||
transform: "translateY(0)",
|
|
||||||
opacity: "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
slideDown: {
|
|
||||||
"0%": {
|
|
||||||
transform: "translateY(-100%)",
|
|
||||||
opacity: "0",
|
|
||||||
},
|
|
||||||
"100%": {
|
|
||||||
transform: "translateY(0)",
|
|
||||||
opacity: "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
slideInRight: {
|
|
||||||
"0%": {
|
|
||||||
transform: "translateX(100%)",
|
|
||||||
opacity: "0",
|
|
||||||
},
|
|
||||||
"100%": {
|
|
||||||
transform: "translateX(0)",
|
|
||||||
opacity: "1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"accordion-down": {
|
|
||||||
from: {
|
|
||||||
height: "0",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: "var(--radix-accordion-content-height)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"accordion-up": {
|
|
||||||
from: {
|
|
||||||
height: "var(--radix-accordion-content-height)",
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: "0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./src/index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: ["selector", "class"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
blue: {
|
||||||
|
'400': '#409CFF',
|
||||||
|
'500': '#0A84FF',
|
||||||
|
'600': '#0060DF'
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
'50': '#fafafa',
|
||||||
|
'100': '#f4f4f5',
|
||||||
|
'200': '#e4e4e7',
|
||||||
|
'300': '#d4d4d8',
|
||||||
|
'400': '#a1a1aa',
|
||||||
|
'500': '#71717a',
|
||||||
|
'600': '#636366',
|
||||||
|
'700': '#48484A',
|
||||||
|
'800': '#3A3A3C',
|
||||||
|
'900': '#2C2C2E',
|
||||||
|
'950': '#1C1C1E'
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
'100': '#d1fae5',
|
||||||
|
'500': '#10b981'
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
'100': '#fee2e2',
|
||||||
|
'500': '#ef4444'
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
'100': '#fef3c7',
|
||||||
|
'500': '#f59e0b'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||||
|
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||||
|
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)'
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: '0.375rem',
|
||||||
|
md: '0.5rem',
|
||||||
|
lg: '0.75rem',
|
||||||
|
xl: '0.875rem'
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
// 使用与之前版本保持一致的系统字体栈
|
||||||
|
sans: [
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'Roboto',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
],
|
||||||
|
mono: [
|
||||||
|
'ui-monospace',
|
||||||
|
'SFMono-Regular',
|
||||||
|
'"SF Mono"',
|
||||||
|
'Consolas',
|
||||||
|
'"Liberation Mono"',
|
||||||
|
'Menlo',
|
||||||
|
'monospace',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.5s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.5s ease-out',
|
||||||
|
'slide-down': 'slideDown 0.3s ease-out',
|
||||||
|
'slide-in-right': 'slideInRight 0.3s ease-out',
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': {
|
||||||
|
opacity: '0'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'translateY(20px)',
|
||||||
|
opacity: '0'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
opacity: '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
opacity: '0'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
opacity: '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
slideInRight: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'translateX(100%)',
|
||||||
|
opacity: '0'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translateX(0)',
|
||||||
|
opacity: '1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'accordion-down': {
|
||||||
|
from: {
|
||||||
|
height: '0'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: 'var(--radix-accordion-content-height)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: {
|
||||||
|
height: 'var(--radix-accordion-content-height)'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
height: '0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -9,12 +9,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["vite.config.mts", "vitest.config.ts"]
|
||||||
"vite.config.ts",
|
}
|
||||||
"vitest.config.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { codeInspectorPlugin } from "code-inspector-plugin";
|
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig({
|
||||||
root: "src",
|
root: "src",
|
||||||
plugins: [
|
plugins: [react()],
|
||||||
command === "serve" &&
|
|
||||||
codeInspectorPlugin({
|
|
||||||
bundler: "vite",
|
|
||||||
}),
|
|
||||||
react(),
|
|
||||||
].filter(Boolean),
|
|
||||||
base: "./",
|
base: "./",
|
||||||
build: {
|
build: {
|
||||||
outDir: "../dist",
|
outDir: "../dist",
|
||||||
@@ -28,4 +21,4 @@ export default defineConfig(({ command }) => ({
|
|||||||
},
|
},
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
envPrefix: ["VITE_", "TAURI_"],
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
}));
|
});
|
||||||