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
|
||||
|
||||
@@ -5,64 +5,6 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.9.0-3] - 2025-12-29
|
||||
|
||||
### Beta Release
|
||||
|
||||
Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features.
|
||||
|
||||
### Added
|
||||
|
||||
- **Universal Provider** - Support for universal provider configurations (#348)
|
||||
- **Provider Search Filter** - Quick filter to find providers by name (#435)
|
||||
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
|
||||
- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)
|
||||
- **Usage Model Extraction** - Extract model info from usage statistics (#455)
|
||||
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
|
||||
- **Exit Animations** - Added exit animation to FullScreenPanel dialogs
|
||||
- **Fade Transitions** - Smooth fade transitions for app/view/panel switching
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Windows
|
||||
- Wrap npx/npm commands with `cmd /c` for MCP export
|
||||
- Prevent terminal windows from appearing during version check
|
||||
|
||||
#### macOS
|
||||
- Use .app bundle path for autostart to prevent terminal window popup
|
||||
|
||||
#### UI
|
||||
- Resolve Dialog/Modal not opening on first click (#492)
|
||||
- Improve dark mode text contrast for form labels
|
||||
- Reduce header spacing and fix layout shift on view switch
|
||||
- Prevent header layout shift when switching views
|
||||
|
||||
#### Database & Schema
|
||||
- Add missing base columns migration for proxy_config
|
||||
- Add backward compatibility check for proxy_config seed insert
|
||||
|
||||
#### Other
|
||||
- Use local timezone and robust DST handling in usage stats (#500)
|
||||
- Remove deprecated `sync_enabled_to_codex` call
|
||||
- Gracefully handle invalid Codex config.toml during MCP sync
|
||||
- Add missing translations for reasoning model and OpenRouter compat mode
|
||||
|
||||
### Improved
|
||||
|
||||
- **macOS Tray** - Use macOS tray template icon
|
||||
- **Header Alignment** - Remove macOS titlebar tint, align custom header
|
||||
- **Shadow Removal** - Cleaner UI by removing shadow styles
|
||||
- **Code Inspector** - Added code-inspector-plugin for development
|
||||
- **i18n** - Complete internationalization for usage panel and settings
|
||||
- **Sponsor Logos** - Made sponsor logos clickable
|
||||
|
||||
### Stats
|
||||
|
||||
- 35 commits since v3.9.0-2
|
||||
- 5 files changed in test/lint fixes
|
||||
|
||||
---
|
||||
|
||||
## [3.9.0-1] - 2025-12-18
|
||||
|
||||
### Beta Release
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 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://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -15,7 +15,7 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
## ❤️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)!
|
||||
|
||||
@@ -23,18 +23,19 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
|
||||
<table>
|
||||
<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>
|
||||
</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>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 width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></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>
|
||||
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
|
||||
<td>Thanks to 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 width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you’ll receive an extra 10% bonus credit on your first top-up!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
@@ -47,7 +48,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
||||
|
||||
## 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)**
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 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://tauri.app/)
|
||||
[](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% オフになります!
|
||||
|
||||
@@ -23,18 +23,20 @@
|
||||
|
||||
<table>
|
||||
<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>
|
||||
</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>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
|
||||
<td>ShanDianShuo のご支援に感謝します!ShanDianShuo はローカルファーストの音声入力ツールで、ミリ秒遅延・データは端末から外に出ず・キーボード入力の 4 倍の速度・AI 自動補正・プライバシー優先で完全無料。Claude Code と組み合わせればコーディング効率が倍増します。<a href="https://www.shandianshuo.cn">Mac/Win 版を無料ダウンロード</a></td>
|
||||
</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>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 width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
@@ -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)**
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 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://tauri.app/)
|
||||
[](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)购买可以享受九折优惠。
|
||||
|
||||
@@ -23,20 +23,20 @@
|
||||
|
||||
<table>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
## 界面预览
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
|
||||
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
|
||||
|
||||
**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,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.cjs",
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.9.0-3",
|
||||
"version": "3.9.0-2",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
"build": "pnpm tauri build",
|
||||
@@ -28,7 +27,6 @@
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"msw": "^2.11.6",
|
||||
@@ -36,7 +34,7 @@
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^2.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -87,4 +85,4 @@
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,7 +701,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.9.0-3"
|
||||
version = "3.9.0-2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.9.0-3"
|
||||
version = "3.9.0-2"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
use crate::error::AppError;
|
||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||
|
||||
/// 获取 macOS 上的 .app bundle 路径
|
||||
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<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 实例
|
||||
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
|
||||
let app_name = "CC Switch";
|
||||
let exe_path =
|
||||
let app_path =
|
||||
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
|
||||
|
||||
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
|
||||
#[cfg(target_os = "macos")]
|
||||
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let app_path = exe_path;
|
||||
|
||||
// 使用 AutoLaunchBuilder 消除平台差异
|
||||
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
|
||||
// Windows/Linux: 使用注册表/XDG autostart
|
||||
// Windows/Linux: new() 接受 3 参数
|
||||
// macOS: new() 接受 4 参数(含 hidden 参数)
|
||||
// Builder 模式自动处理这些差异
|
||||
let auto_launch = AutoLaunchBuilder::new()
|
||||
.set_app_name(app_name)
|
||||
.set_app_path(&app_path.to_string_lossy())
|
||||
@@ -67,50 +47,3 @@ pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
|
||||
.is_enabled()
|
||||
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_valid() {
|
||||
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(std::path::PathBuf::from("/Applications/CC Switch.app"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_with_spaces() {
|
||||
let exe_path =
|
||||
std::path::Path::new("/Users/test/My Apps/CC Switch.app/Contents/MacOS/CC Switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(std::path::PathBuf::from(
|
||||
"/Users/test/My Apps/CC Switch.app"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_not_in_bundle() {
|
||||
let exe_path = std::path::Path::new("/usr/local/bin/cc-switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_get_macos_app_bundle_path_dev_build() {
|
||||
// 开发环境下的路径通常不在 .app bundle 内
|
||||
let exe_path = std::path::Path::new("/Users/dev/project/target/debug/cc-switch");
|
||||
let result = get_macos_app_bundle_path(exe_path);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,64 +7,6 @@ use std::path::{Path, PathBuf};
|
||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||
use crate::error::AppError;
|
||||
|
||||
/// 需要在 Windows 上用 cmd /c 包装的命令
|
||||
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
|
||||
|
||||
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
|
||||
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
|
||||
#[cfg(windows)]
|
||||
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
|
||||
// 只处理 stdio 类型(默认或显式)
|
||||
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||
if server_type != "stdio" {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 已经是 cmd 的不重复包装
|
||||
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取命令名(去掉 .cmd 后缀和路径)
|
||||
let cmd_name = Path::new(cmd)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(cmd);
|
||||
|
||||
let needs_wrap = WINDOWS_WRAP_COMMANDS
|
||||
.iter()
|
||||
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
|
||||
|
||||
if !needs_wrap {
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建新的 args: ["/c", "原命令", ...原args]
|
||||
let original_args = obj
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
|
||||
new_args.extend(original_args);
|
||||
|
||||
obj.insert("command".into(), Value::String("cmd".into()));
|
||||
obj.insert("args".into(), Value::Array(new_args));
|
||||
}
|
||||
|
||||
/// 非 Windows 平台无需处理
|
||||
#[cfg(not(windows))]
|
||||
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
||||
// 非 Windows 平台不做任何处理
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpStatus {
|
||||
@@ -397,9 +339,6 @@ pub fn set_mcp_servers_map(
|
||||
obj.remove("homepage");
|
||||
obj.remove("docs");
|
||||
|
||||
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
out.insert(id.clone(), Value::Object(obj));
|
||||
}
|
||||
|
||||
@@ -413,136 +352,3 @@ pub fn set_mcp_servers_map(
|
||||
write_json_value(&path, &root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// 测试 Windows 命令包装功能
|
||||
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_npx() {
|
||||
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(obj["command"], "cmd");
|
||||
assert_eq!(
|
||||
obj["args"],
|
||||
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 非 Windows 平台不做任何处理
|
||||
assert_eq!(obj["command"], "npx");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_npm() {
|
||||
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(obj["command"], "cmd");
|
||||
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_already_cmd() {
|
||||
// 已经是 cmd 的不应该重复包装
|
||||
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
assert_eq!(obj["command"], "cmd");
|
||||
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
|
||||
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_http_type_skipped() {
|
||||
// http 类型不应该被处理
|
||||
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
assert!(!obj.contains_key("command"));
|
||||
assert_eq!(obj["url"], "https://example.com/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_other_command_skipped() {
|
||||
// 非目标命令(如 python)不应该被包装
|
||||
let mut obj = json!({"command": "python", "args": ["server.py"]})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
|
||||
assert_eq!(obj["command"], "python");
|
||||
assert_eq!(obj["args"], json!(["server.py"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_no_args() {
|
||||
// 没有 args 的情况
|
||||
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(obj["command"], "cmd");
|
||||
assert_eq!(obj["args"], json!(["/c", "npx"]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_with_cmd_suffix() {
|
||||
// 处理 npx.cmd 格式
|
||||
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(obj["command"], "cmd");
|
||||
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_command_for_windows_case_insensitive() {
|
||||
// 大小写不敏感
|
||||
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
wrap_command_for_windows(&mut obj);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(obj["command"], "cmd");
|
||||
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,18 +248,12 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
if tool_path.exists() {
|
||||
// 构建 PATH 环境变量,确保 node 可被找到
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let new_path = format!("{};{}", path.display(), current_path);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||
Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", &new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
|
||||
@@ -229,97 +229,3 @@ pub fn update_providers_sort_order(
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(Universal Provider)命令
|
||||
// ============================================================================
|
||||
|
||||
use crate::provider::UniversalProvider;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// 统一供应商同步完成事件的 payload
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct UniversalProviderSyncedEvent {
|
||||
/// 操作类型: "upsert" | "delete" | "sync"
|
||||
pub action: String,
|
||||
/// 统一供应商 ID
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// 发送统一供应商同步事件,通知前端刷新供应商列表
|
||||
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
|
||||
let _ = app.emit(
|
||||
"universal-provider-synced",
|
||||
UniversalProviderSyncedEvent {
|
||||
action: action.to_string(),
|
||||
id: id.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取所有统一供应商
|
||||
#[tauri::command]
|
||||
pub fn get_universal_providers(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<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)
|
||||
}
|
||||
|
||||
@@ -184,16 +184,17 @@ pub async fn reset_circuit_breaker(
|
||||
.await?;
|
||||
|
||||
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
|
||||
// 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行
|
||||
let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {
|
||||
Ok(config) => (config.enabled, config.auto_failover_enabled),
|
||||
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
|
||||
Ok(config) => config.auto_failover_enabled,
|
||||
Err(e) => {
|
||||
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
|
||||
(false, false)
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
|
||||
if auto_failover_enabled && state.proxy_service.is_running().await {
|
||||
// 获取当前供应商 ID
|
||||
let current_id = db
|
||||
.get_current_provider(&app_type)
|
||||
|
||||
@@ -19,10 +19,9 @@ pub fn get_usage_summary(
|
||||
#[tauri::command]
|
||||
pub fn get_usage_trends(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
days: u32,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
state.db.get_daily_trends(start_date, end_date)
|
||||
state.db.get_daily_trends(days)
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
|
||||
@@ -10,7 +10,6 @@ pub mod proxy;
|
||||
pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 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()))?;
|
||||
|
||||
// 初始化三行数据(每应用不同默认值)
|
||||
//
|
||||
// 兼容旧数据库:
|
||||
// - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert;
|
||||
// - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。
|
||||
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||
circuit_error_rate_threshold, circuit_min_requests)
|
||||
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 9. Provider Health 表
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
|
||||
@@ -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 表
|
||||
let _ = conn.execute(
|
||||
"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 数据库)
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
@@ -437,32 +405,6 @@ impl Database {
|
||||
|
||||
// 添加代理超时配置字段
|
||||
if Self::table_exists(conn, "proxy_config")? {
|
||||
// 兼容旧版本缺失的基础字段
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"proxy_enabled",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"listen_address",
|
||||
"TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"listen_port",
|
||||
"INTEGER NOT NULL DEFAULT 5000",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"enable_logging",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
)?;
|
||||
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
@@ -765,9 +707,9 @@ impl Database {
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_data = [
|
||||
// Claude 4.5 系列 (Latest Models)
|
||||
// Claude 4.5 系列
|
||||
(
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-5",
|
||||
"Claude Opus 4.5",
|
||||
"5",
|
||||
"25",
|
||||
@@ -775,7 +717,7 @@ impl Database {
|
||||
"6.25",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-sonnet-4-5",
|
||||
"Claude Sonnet 4.5",
|
||||
"3",
|
||||
"15",
|
||||
@@ -783,24 +725,16 @@ impl Database {
|
||||
"3.75",
|
||||
),
|
||||
(
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-haiku-4-5",
|
||||
"Claude Haiku 4.5",
|
||||
"1",
|
||||
"5",
|
||||
"0.10",
|
||||
"1.25",
|
||||
),
|
||||
// Claude 4 系列 (Legacy Models)
|
||||
// Claude 4.1 系列
|
||||
(
|
||||
"claude-opus-4-20250514",
|
||||
"Claude Opus 4",
|
||||
"15",
|
||||
"75",
|
||||
"1.50",
|
||||
"18.75",
|
||||
),
|
||||
(
|
||||
"claude-opus-4-1-20250805",
|
||||
"claude-opus-4-1",
|
||||
"Claude Opus 4.1",
|
||||
"15",
|
||||
"75",
|
||||
@@ -808,8 +742,17 @@ impl Database {
|
||||
"18.75",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-20250514",
|
||||
"Claude Sonnet 4",
|
||||
"claude-sonnet-4-1",
|
||||
"Claude Sonnet 4.1",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// Claude 3.7 系列
|
||||
(
|
||||
"claude-sonnet-3-7",
|
||||
"Claude Sonnet 3.7",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
@@ -817,167 +760,38 @@ impl Database {
|
||||
),
|
||||
// Claude 3.5 系列
|
||||
(
|
||||
"claude-3-5-haiku-20241022",
|
||||
"Claude 3.5 Haiku",
|
||||
"0.80",
|
||||
"4",
|
||||
"0.08",
|
||||
"1",
|
||||
),
|
||||
(
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"Claude 3.5 Sonnet",
|
||||
"claude-sonnet-3-5",
|
||||
"Claude Sonnet 3.5",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// GPT-5.2 系列
|
||||
("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-medium", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"),
|
||||
("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
|
||||
(
|
||||
"gpt-5.2-codex-low",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
"claude-haiku-3-5",
|
||||
"Claude Haiku 3.5",
|
||||
"0.80",
|
||||
"4",
|
||||
"0.08",
|
||||
"1",
|
||||
),
|
||||
(
|
||||
"gpt-5.2-codex-medium",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.2-codex-high",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.2-codex-xhigh",
|
||||
"GPT-5.2 Codex",
|
||||
"1.75",
|
||||
"14",
|
||||
"0.175",
|
||||
"0",
|
||||
),
|
||||
// GPT-5.1 系列
|
||||
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-medium", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
||||
(
|
||||
"gpt-5.1-codex-mini",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.1-codex-max",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.1-codex-max-high",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5.1-codex-max-xhigh",
|
||||
"GPT-5.1 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
// GPT-5 系列
|
||||
// GPT-5 系列(model_id 使用短横线格式)
|
||||
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-medium", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-high", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||||
("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
|
||||
(
|
||||
"gpt-5-codex-medium",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-high",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-mini",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-mini-medium",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gpt-5-codex-mini-high",
|
||||
"GPT-5 Codex",
|
||||
"1.25",
|
||||
"10",
|
||||
"0.125",
|
||||
"0",
|
||||
),
|
||||
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
|
||||
// Gemini 3 系列
|
||||
(
|
||||
"gemini-3-pro-preview",
|
||||
"Gemini 3 Pro Preview",
|
||||
"2",
|
||||
"12",
|
||||
"0.2",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// Gemini 2.5 系列(model_id 使用短横线格式)
|
||||
(
|
||||
"gemini-3-flash-preview",
|
||||
"Gemini 3 Flash Preview",
|
||||
"0.5",
|
||||
"3",
|
||||
"0.05",
|
||||
"0",
|
||||
),
|
||||
// Gemini 2.5 系列
|
||||
(
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2-5-pro",
|
||||
"Gemini 2.5 Pro",
|
||||
"1.25",
|
||||
"10",
|
||||
@@ -985,75 +799,13 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2-5-flash",
|
||||
"Gemini 2.5 Flash",
|
||||
"0.3",
|
||||
"2.5",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// ====== 国产模型 (CNY/1M tokens) ======
|
||||
// Doubao (字节跳动)
|
||||
(
|
||||
"doubao-seed-code",
|
||||
"Doubao Seed Code",
|
||||
"1.20",
|
||||
"8.00",
|
||||
"0.24",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek 系列
|
||||
(
|
||||
"deepseek-v3.2",
|
||||
"DeepSeek V3.2",
|
||||
"2.00",
|
||||
"3.00",
|
||||
"0.40",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-v3.1",
|
||||
"DeepSeek V3.1",
|
||||
"4.00",
|
||||
"12.00",
|
||||
"0.80",
|
||||
"0",
|
||||
),
|
||||
("deepseek-v3", "DeepSeek V3", "2.00", "8.00", "0.40", "0"),
|
||||
// Kimi (月之暗面)
|
||||
(
|
||||
"kimi-k2-thinking",
|
||||
"Kimi K2 Thinking",
|
||||
"4.00",
|
||||
"16.00",
|
||||
"1.00",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2-0905", "Kimi K2", "4.00", "16.00", "1.00", "0"),
|
||||
(
|
||||
"kimi-k2-turbo",
|
||||
"Kimi K2 Turbo",
|
||||
"8.00",
|
||||
"58.00",
|
||||
"1.00",
|
||||
"0",
|
||||
),
|
||||
// MiniMax 系列
|
||||
("minimax-m2.1", "MiniMax M2.1", "2.10", "8.40", "0.21", "0"),
|
||||
(
|
||||
"minimax-m2.1-lightning",
|
||||
"MiniMax M2.1 Lightning",
|
||||
"2.10",
|
||||
"16.80",
|
||||
"0.21",
|
||||
"0",
|
||||
),
|
||||
("minimax-m2", "MiniMax M2", "2.10", "8.40", "0.21", "0"),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "2.00", "8.00", "0.40", "0"),
|
||||
("glm-4.6", "GLM-4.6", "2.00", "8.00", "0.40", "0"),
|
||||
// Mimo (小米)
|
||||
("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "0"),
|
||||
];
|
||||
|
||||
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
|
||||
|
||||
@@ -53,6 +53,7 @@ const LEGACY_SCHEMA_SQL: &str = r#"
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColumnInfo {
|
||||
name: String,
|
||||
r#type: String,
|
||||
notnull: i64,
|
||||
default: Option<String>,
|
||||
@@ -64,9 +65,10 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
|
||||
.expect("prepare pragma");
|
||||
let mut rows = stmt.query([]).expect("query pragma");
|
||||
while let Some(row) = rows.next().expect("read row") {
|
||||
let column_name: String = row.get(1).expect("name");
|
||||
if column_name.eq_ignore_ascii_case(column) {
|
||||
let name: String = row.get(1).expect("name");
|
||||
if name.eq_ignore_ascii_case(column) {
|
||||
return ColumnInfo {
|
||||
name,
|
||||
r#type: row.get::<_, String>(2).expect("type"),
|
||||
notnull: row.get::<_, i64>(3).expect("notnull"),
|
||||
default: row.get::<_, Option<String>>(4).ok().flatten(),
|
||||
@@ -199,53 +201,6 @@ fn migration_aligns_column_defaults_and_types() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
// 模拟测试版 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]
|
||||
fn dry_run_does_not_write_to_disk() {
|
||||
// Create minimal valid config for migration
|
||||
@@ -294,10 +249,9 @@ fn dry_run_validates_schema_compatibility() {
|
||||
},
|
||||
);
|
||||
|
||||
let manager = ProviderManager {
|
||||
providers,
|
||||
current: "test-provider".to_string(),
|
||||
};
|
||||
let mut manager = ProviderManager::default();
|
||||
manager.providers = providers;
|
||||
manager.current = "test-provider".to_string();
|
||||
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), manager);
|
||||
|
||||
@@ -375,7 +375,7 @@ fn test_parse_prompt_deeplink() {
|
||||
assert_eq!(request.name.unwrap(), "test");
|
||||
assert_eq!(request.content.unwrap(), content_b64);
|
||||
assert_eq!(request.description.unwrap(), "desc");
|
||||
assert!(request.enabled.unwrap());
|
||||
assert_eq!(request.enabled.unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -391,13 +391,13 @@ fn test_parse_mcp_deeplink() {
|
||||
assert_eq!(request.resource, "mcp");
|
||||
assert_eq!(request.apps.unwrap(), "claude,codex");
|
||||
assert_eq!(request.config.unwrap(), config_b64);
|
||||
assert!(request.enabled.unwrap());
|
||||
assert_eq!(request.enabled.unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_deeplink() {
|
||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
let request = parse_deeplink_url(&url).unwrap();
|
||||
|
||||
assert_eq!(request.resource, "skill");
|
||||
assert_eq!(request.repo.unwrap(), "owner/repo");
|
||||
|
||||
@@ -52,10 +52,6 @@ pub enum AppError {
|
||||
},
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(String),
|
||||
#[error("所有供应商已熔断,无可用渠道")]
|
||||
AllProvidersCircuitOpen,
|
||||
#[error("未配置供应商")]
|
||||
NoProvidersConfigured,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
|
||||
@@ -694,12 +694,6 @@ pub fn run() {
|
||||
commands::get_stream_check_config,
|
||||
commands::save_stream_check_config,
|
||||
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
|
||||
|
||||
@@ -359,14 +359,9 @@ pub fn sync_single_server_to_codex(
|
||||
let mut doc = if config_path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
|
||||
match content.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
|
||||
toml_edit::DocumentMut::new()
|
||||
}
|
||||
}
|
||||
content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
|
||||
} else {
|
||||
toml_edit::DocumentMut::new()
|
||||
};
|
||||
@@ -414,14 +409,9 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
let content =
|
||||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||||
|
||||
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
|
||||
let mut doc = match content.parse::<toml_edit::DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mut doc = content
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
|
||||
|
||||
// 从正确的位置删除:[mcp_servers]
|
||||
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
|
||||
|
||||
@@ -173,285 +173,3 @@ impl ProviderManager {
|
||||
&self.providers
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(Universal Provider)- 跨应用共享配置
|
||||
// ============================================================================
|
||||
|
||||
/// 统一供应商的应用启用状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UniversalProviderApps {
|
||||
#[serde(default)]
|
||||
pub claude: bool,
|
||||
#[serde(default)]
|
||||
pub codex: bool,
|
||||
#[serde(default)]
|
||||
pub gemini: bool,
|
||||
}
|
||||
|
||||
/// Claude 模型配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ClaudeModelConfig {
|
||||
/// 主模型
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<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}"
|
||||
model_reasoning_effort = "{reasoning_effort}"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.newapi]
|
||||
name = "NewAPI"
|
||||
base_url = "{codex_base_url}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true"#
|
||||
);
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
//! 请求体过滤模块
|
||||
//!
|
||||
//! 过滤不应透传到上游的私有参数,防止内部信息泄露。
|
||||
//!
|
||||
//! ## 过滤规则
|
||||
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
|
||||
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
|
||||
//! - 支持嵌套对象和数组的深度过滤
|
||||
//!
|
||||
//! ## 使用场景
|
||||
//! - `_internal_id`: 内部追踪 ID
|
||||
//! - `_debug_mode`: 调试标记
|
||||
//! - `_session_token`: 会话令牌
|
||||
//! - `_client_version`: 客户端版本
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// 过滤私有参数(以 `_` 开头的字段)
|
||||
///
|
||||
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始请求体
|
||||
///
|
||||
/// # Returns
|
||||
/// 过滤后的请求体
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let input = json!({
|
||||
/// "model": "claude-3",
|
||||
/// "_internal_id": "abc123",
|
||||
/// "messages": [{"role": "user", "content": "hello", "_token": "secret"}]
|
||||
/// });
|
||||
/// let output = filter_private_params(input);
|
||||
/// // output 中不包含 _internal_id 和 _token
|
||||
/// ```
|
||||
#[cfg(test)]
|
||||
pub fn filter_private_params(body: Value) -> Value {
|
||||
filter_private_params_with_whitelist(body, &[])
|
||||
}
|
||||
|
||||
/// 过滤私有参数(支持白名单)
|
||||
///
|
||||
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段,
|
||||
/// 但保留白名单中指定的字段。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始请求体
|
||||
/// * `whitelist` - 白名单字段列表(不过滤这些字段)
|
||||
///
|
||||
/// # Returns
|
||||
/// 过滤后的请求体
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let input = json!({
|
||||
/// "model": "claude-3",
|
||||
/// "_metadata": {"key": "value"}, // 白名单中,保留
|
||||
/// "_internal_id": "abc123" // 不在白名单中,过滤
|
||||
/// });
|
||||
/// let output = filter_private_params_with_whitelist(input, &["_metadata"]);
|
||||
/// // output 包含 _metadata,不包含 _internal_id
|
||||
/// ```
|
||||
pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {
|
||||
let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();
|
||||
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
|
||||
}
|
||||
|
||||
/// 递归过滤实现
|
||||
#[cfg(test)]
|
||||
fn filter_recursive(value: Value, removed_keys: &mut Vec<String>) -> Value {
|
||||
filter_recursive_with_whitelist(value, removed_keys, &HashSet::new())
|
||||
}
|
||||
|
||||
/// 递归过滤实现(支持白名单)
|
||||
fn filter_recursive_with_whitelist(
|
||||
value: Value,
|
||||
removed_keys: &mut Vec<String>,
|
||||
whitelist: &HashSet<&str>,
|
||||
) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let filtered: serde_json::Map<String, Value> = map
|
||||
.into_iter()
|
||||
.filter_map(|(key, val)| {
|
||||
// 以 _ 开头且不在白名单中的字段被过滤
|
||||
if key.starts_with('_') && !whitelist.contains(key.as_str()) {
|
||||
removed_keys.push(key);
|
||||
None
|
||||
} else {
|
||||
Some((
|
||||
key,
|
||||
filter_recursive_with_whitelist(val, removed_keys, whitelist),
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 仅在有过滤时记录日志(避免每次请求都打印)
|
||||
if !removed_keys.is_empty() {
|
||||
log::debug!("[BodyFilter] 过滤私有参数: {removed_keys:?}");
|
||||
removed_keys.clear();
|
||||
}
|
||||
|
||||
Value::Object(filtered)
|
||||
}
|
||||
Value::Array(arr) => Value::Array(
|
||||
arr.into_iter()
|
||||
.map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist))
|
||||
.collect(),
|
||||
),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_filter_top_level_private_params() {
|
||||
let input = json!({
|
||||
"model": "claude-3",
|
||||
"_internal_id": "abc123",
|
||||
"_debug": true,
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let output = filter_private_params(input);
|
||||
|
||||
assert!(output.get("model").is_some());
|
||||
assert!(output.get("max_tokens").is_some());
|
||||
assert!(output.get("_internal_id").is_none());
|
||||
assert!(output.get("_debug").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_nested_private_params() {
|
||||
let input = json!({
|
||||
"model": "claude-3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hello",
|
||||
"_session_token": "secret"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"user_id": "user-1",
|
||||
"_tracking_id": "track-1"
|
||||
}
|
||||
});
|
||||
|
||||
let output = filter_private_params(input);
|
||||
|
||||
// 顶级字段保留
|
||||
assert!(output.get("model").is_some());
|
||||
assert!(output.get("messages").is_some());
|
||||
assert!(output.get("metadata").is_some());
|
||||
|
||||
// messages 数组中的私有参数被过滤
|
||||
let messages = output.get("messages").unwrap().as_array().unwrap();
|
||||
assert!(messages[0].get("role").is_some());
|
||||
assert!(messages[0].get("content").is_some());
|
||||
assert!(messages[0].get("_session_token").is_none());
|
||||
|
||||
// metadata 对象中的私有参数被过滤
|
||||
let metadata = output.get("metadata").unwrap();
|
||||
assert!(metadata.get("user_id").is_some());
|
||||
assert!(metadata.get("_tracking_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_deeply_nested() {
|
||||
let input = json!({
|
||||
"level1": {
|
||||
"level2": {
|
||||
"level3": {
|
||||
"keep": "value",
|
||||
"_remove": "secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let output = filter_private_params(input);
|
||||
|
||||
let level3 = output
|
||||
.get("level1")
|
||||
.unwrap()
|
||||
.get("level2")
|
||||
.unwrap()
|
||||
.get("level3")
|
||||
.unwrap();
|
||||
|
||||
assert!(level3.get("keep").is_some());
|
||||
assert!(level3.get("_remove").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_array_of_objects() {
|
||||
let input = json!({
|
||||
"items": [
|
||||
{"id": 1, "_secret": "a"},
|
||||
{"id": 2, "_secret": "b"},
|
||||
{"id": 3, "_secret": "c"}
|
||||
]
|
||||
});
|
||||
|
||||
let output = filter_private_params(input);
|
||||
let items = output.get("items").unwrap().as_array().unwrap();
|
||||
|
||||
for item in items {
|
||||
assert!(item.get("id").is_some());
|
||||
assert!(item.get("_secret").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_private_params() {
|
||||
let input = json!({
|
||||
"model": "claude-3",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
});
|
||||
|
||||
let output = filter_private_params(input.clone());
|
||||
|
||||
// 无私有参数时,输出应与输入相同
|
||||
assert_eq!(input, output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_object() {
|
||||
let input = json!({});
|
||||
let output = filter_private_params(input);
|
||||
assert_eq!(output, json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_primitive_values() {
|
||||
// 原始值不应被修改
|
||||
assert_eq!(filter_private_params(json!(42)), json!(42));
|
||||
assert_eq!(filter_private_params(json!("string")), json!("string"));
|
||||
assert_eq!(filter_private_params(json!(true)), json!(true));
|
||||
assert_eq!(filter_private_params(json!(null)), json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitelist_preserves_private_params() {
|
||||
let input = json!({
|
||||
"model": "claude-3",
|
||||
"_metadata": {"key": "value"},
|
||||
"_internal_id": "abc123",
|
||||
"_stream_options": {"include_usage": true}
|
||||
});
|
||||
|
||||
let whitelist = vec!["_metadata".to_string(), "_stream_options".to_string()];
|
||||
let output = filter_private_params_with_whitelist(input, &whitelist);
|
||||
|
||||
// 白名单中的字段保留
|
||||
assert!(output.get("_metadata").is_some());
|
||||
assert!(output.get("_stream_options").is_some());
|
||||
// 不在白名单中的私有字段被过滤
|
||||
assert!(output.get("_internal_id").is_none());
|
||||
// 普通字段保留
|
||||
assert!(output.get("model").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitelist_nested() {
|
||||
let input = json!({
|
||||
"data": {
|
||||
"_allowed": "keep",
|
||||
"_forbidden": "remove",
|
||||
"normal": "value"
|
||||
}
|
||||
});
|
||||
|
||||
let whitelist = vec!["_allowed".to_string()];
|
||||
let output = filter_private_params_with_whitelist(input, &whitelist);
|
||||
|
||||
let data = output.get("data").unwrap();
|
||||
assert!(data.get("_allowed").is_some());
|
||||
assert!(data.get("_forbidden").is_none());
|
||||
assert!(data.get("normal").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_whitelist_same_as_default() {
|
||||
let input = json!({
|
||||
"model": "claude-3",
|
||||
"_internal_id": "abc123"
|
||||
});
|
||||
|
||||
let output1 = filter_private_params(input.clone());
|
||||
let output2 = filter_private_params_with_whitelist(input, &[]);
|
||||
|
||||
assert_eq!(output1, output2);
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,6 @@ pub enum ProxyError {
|
||||
#[error("无可用的Provider")]
|
||||
NoAvailableProvider,
|
||||
|
||||
#[error("所有供应商已熔断,无可用渠道")]
|
||||
AllProvidersCircuitOpen,
|
||||
|
||||
#[error("未配置供应商")]
|
||||
NoProvidersConfigured,
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[error("Provider不健康: {0}")]
|
||||
ProviderUnhealthy(String),
|
||||
@@ -117,12 +111,6 @@ impl IntoResponse for ProxyError {
|
||||
ProxyError::NoAvailableProvider => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::AllProvidersCircuitOpen => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::NoProvidersConfigured => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
ProxyError::ProviderUnhealthy(_) => {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
|
||||
}
|
||||
|
||||
@@ -27,12 +27,6 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
||||
// 无可用 Provider:503 Service Unavailable
|
||||
ProxyError::NoAvailableProvider => 503,
|
||||
|
||||
// 所有供应商已熔断:503 Service Unavailable
|
||||
ProxyError::AllProvidersCircuitOpen => 503,
|
||||
|
||||
// 未配置供应商:503 Service Unavailable
|
||||
ProxyError::NoProvidersConfigured => 503,
|
||||
|
||||
// 重试耗尽:503 Service Unavailable
|
||||
ProxyError::MaxRetriesExceeded => 503,
|
||||
|
||||
@@ -63,8 +57,6 @@ pub fn get_error_message(error: &ProxyError) -> String {
|
||||
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
|
||||
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
|
||||
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
|
||||
ProxyError::AllProvidersCircuitOpen => "所有供应商已熔断,无可用渠道".to_string(),
|
||||
ProxyError::NoProvidersConfigured => "未配置供应商".to_string(),
|
||||
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
|
||||
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
|
||||
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
|
||||
|
||||
@@ -81,21 +81,6 @@ impl FailoverSwitchManager {
|
||||
provider_id: &str,
|
||||
provider_name: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
// 检查该应用是否已被代理接管(enabled=true)
|
||||
// 只有被接管的应用才允许执行故障转移切换
|
||||
let app_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
Ok(config) => config.enabled,
|
||||
Err(e) => {
|
||||
log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
if !app_enabled {
|
||||
log::info!("[Failover] {app_type} 未被代理接管(enabled=false),跳过切换");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
|
||||
|
||||
// 1. 更新数据库 is_current
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! 请求转发器
|
||||
//!
|
||||
//! 负责将请求转发到上游Provider,支持故障转移
|
||||
//! 负责将请求转发到上游Provider,支持重试和故障转移
|
||||
|
||||
use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
@@ -18,71 +17,6 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Headers 黑名单 - 不透传到上游的 Headers
|
||||
///
|
||||
/// 参考 Claude Code Hub 设计,过滤以下类别:
|
||||
/// 1. 认证类(会被覆盖)
|
||||
/// 2. 连接类(由 HTTP 客户端管理)
|
||||
/// 3. 代理转发类
|
||||
/// 4. CDN/云服务商特定头
|
||||
/// 5. 请求追踪类
|
||||
/// 6. 浏览器特定头(可能被上游检测)
|
||||
///
|
||||
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
|
||||
const HEADER_BLACKLIST: &[&str] = &[
|
||||
// 认证类(会被覆盖)
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
// 连接类
|
||||
"host",
|
||||
"content-length",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
// 编码类(会被覆盖为 identity)
|
||||
"accept-encoding",
|
||||
// 代理转发类(保留 x-forwarded-for 和 x-real-ip)
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
// CDN/云服务商特定头
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"cf-ray",
|
||||
"cf-visitor",
|
||||
"true-client-ip",
|
||||
"fastly-client-ip",
|
||||
"x-azure-clientip",
|
||||
"x-azure-fdid",
|
||||
"x-azure-ref",
|
||||
"akamai-origin-hop",
|
||||
"x-akamai-config-log-detail",
|
||||
// 请求追踪类
|
||||
"x-request-id",
|
||||
"x-correlation-id",
|
||||
"x-trace-id",
|
||||
"x-amzn-trace-id",
|
||||
"x-b3-traceid",
|
||||
"x-b3-spanid",
|
||||
"x-b3-parentspanid",
|
||||
"x-b3-sampled",
|
||||
"traceparent",
|
||||
"tracestate",
|
||||
// 浏览器特定头(可能被上游检测为非 CLI 请求)
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-dest",
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"accept-language",
|
||||
// anthropic-beta 单独处理,避免重复
|
||||
"anthropic-beta",
|
||||
// 客户端 IP 单独处理(默认透传)
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
];
|
||||
|
||||
pub struct ForwardResult {
|
||||
pub response: Response,
|
||||
pub provider: Provider,
|
||||
@@ -97,6 +31,8 @@ pub struct RequestForwarder {
|
||||
client: Client,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
/// 单个 Provider 内的最大重试次数
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
/// 故障转移切换管理器
|
||||
@@ -112,6 +48,7 @@ impl RequestForwarder {
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
non_streaming_timeout: u64,
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
@@ -140,6 +77,7 @@ impl RequestForwarder {
|
||||
Self {
|
||||
client,
|
||||
router,
|
||||
max_retries,
|
||||
status,
|
||||
current_providers,
|
||||
failover_manager,
|
||||
@@ -148,6 +86,59 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 对单个 Provider 执行请求(带重试)
|
||||
///
|
||||
/// 在同一个 Provider 上最多重试 max_retries 次,使用指数退避
|
||||
async fn forward_with_provider_retry(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<Response, ProxyError> {
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
if attempt > 0 {
|
||||
// 指数退避:100ms, 200ms, 400ms, ...
|
||||
let delay_ms = 100 * 2u64.pow(attempt as u32 - 1);
|
||||
log::info!(
|
||||
"[{}] 重试第 {}/{} 次(等待 {}ms)",
|
||||
adapter.name(),
|
||||
attempt,
|
||||
self.max_retries,
|
||||
delay_ms
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
match self
|
||||
.forward(provider, endpoint, body, headers, adapter)
|
||||
.await
|
||||
{
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e) => {
|
||||
// 只有“同一 Provider 内可重试”的错误才继续重试
|
||||
if !self.should_retry_same_provider(&e) {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[{}] Provider {} 第 {} 次请求失败: {}",
|
||||
adapter.name(),
|
||||
provider.name,
|
||||
attempt + 1,
|
||||
e
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
||||
}
|
||||
|
||||
/// 转发请求(带故障转移)
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -233,9 +224,9 @@ impl RequestForwarder {
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
// 转发请求(带单 Provider 内重试)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
@@ -486,28 +477,6 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
|
||||
// ========== 请求体日志(截断显示) ==========
|
||||
let body_str = serde_json::to_string_pretty(&filtered_body)
|
||||
.unwrap_or_else(|_| filtered_body.to_string());
|
||||
let body_preview = if body_str.len() > 2000 {
|
||||
format!(
|
||||
"{}...\n[截断,总长度: {} 字符]",
|
||||
&body_str[..2000],
|
||||
body_str.len()
|
||||
)
|
||||
} else {
|
||||
body_str
|
||||
};
|
||||
log::info!(
|
||||
"[{}] ====== 最终请求体 ======\n{}",
|
||||
adapter.name(),
|
||||
body_preview
|
||||
);
|
||||
|
||||
log::info!(
|
||||
"[{}] 转发请求: {} -> {}",
|
||||
adapter.name(),
|
||||
@@ -518,73 +487,28 @@ impl RequestForwarder {
|
||||
// 构建请求
|
||||
let mut request = self.client.post(&url);
|
||||
|
||||
// ========== 详细 Headers 日志 ==========
|
||||
log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name());
|
||||
for (key, value) in headers {
|
||||
log::info!(
|
||||
"[{}] {}: {:?}",
|
||||
adapter.name(),
|
||||
key.as_str(),
|
||||
value.to_str().unwrap_or("<binary>")
|
||||
);
|
||||
}
|
||||
|
||||
// 过滤黑名单 Headers,保护隐私并避免冲突
|
||||
let mut filtered_headers: Vec<String> = Vec::new();
|
||||
let mut passed_headers: Vec<(String, String)> = Vec::new();
|
||||
// 只透传必要的 Headers(白名单模式)
|
||||
let allowed_headers = [
|
||||
"accept",
|
||||
"user-agent",
|
||||
"x-request-id",
|
||||
"x-stainless-arch",
|
||||
"x-stainless-lang",
|
||||
"x-stainless-os",
|
||||
"x-stainless-package-version",
|
||||
"x-stainless-runtime",
|
||||
"x-stainless-runtime-version",
|
||||
];
|
||||
|
||||
for (key, value) in headers {
|
||||
let key_str = key.as_str().to_lowercase();
|
||||
if HEADER_BLACKLIST.contains(&key_str.as_str()) {
|
||||
filtered_headers.push(key_str);
|
||||
continue;
|
||||
}
|
||||
let value_str = value.to_str().unwrap_or("<binary>").to_string();
|
||||
passed_headers.push((key.as_str().to_string(), value_str.clone()));
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
if !filtered_headers.is_empty() {
|
||||
log::info!(
|
||||
"[{}] ====== 被过滤的 Headers ({}) ======",
|
||||
adapter.name(),
|
||||
filtered_headers.len()
|
||||
);
|
||||
for h in &filtered_headers {
|
||||
log::info!("[{}] - {}", adapter.name(), h);
|
||||
if allowed_headers.contains(&key_str.as_str()) {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 anthropic-beta Header(透传)
|
||||
// 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记
|
||||
if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
request = request.header("anthropic-beta", beta_str);
|
||||
passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string()));
|
||||
log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端 IP 透传(默认开启)
|
||||
if let Some(xff) = headers.get("x-forwarded-for") {
|
||||
if let Ok(xff_str) = xff.to_str() {
|
||||
request = request.header("x-forwarded-for", xff_str);
|
||||
passed_headers.push(("x-forwarded-for".to_string(), xff_str.to_string()));
|
||||
log::debug!("[{}] 透传 x-forwarded-for: {}", adapter.name(), xff_str);
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(real_ip_str) = real_ip.to_str() {
|
||||
request = request.header("x-real-ip", real_ip_str);
|
||||
passed_headers.push(("x-real-ip".to_string(), real_ip_str.to_string()));
|
||||
log::debug!("[{}] 透传 x-real-ip: {}", adapter.name(), real_ip_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用压缩,避免 gzip 流式响应解析错误
|
||||
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
|
||||
request = request.header("accept-encoding", "identity");
|
||||
passed_headers.push(("accept-encoding".to_string(), "identity".to_string()));
|
||||
// 确保 Content-Type 是 json
|
||||
request = request.header("Content-Type", "application/json");
|
||||
|
||||
// 使用适配器添加认证头
|
||||
if let Some(auth) = adapter.extract_auth(provider) {
|
||||
@@ -595,15 +519,6 @@ impl RequestForwarder {
|
||||
auth.masked_key()
|
||||
);
|
||||
request = adapter.add_auth_headers(request, &auth);
|
||||
// 记录认证头(脱敏)
|
||||
passed_headers.push((
|
||||
"authorization".to_string(),
|
||||
format!("Bearer {}...", &auth.api_key[..8.min(auth.api_key.len())]),
|
||||
));
|
||||
passed_headers.push((
|
||||
"x-api-key".to_string(),
|
||||
format!("{}...", &auth.api_key[..8.min(auth.api_key.len())]),
|
||||
));
|
||||
} else {
|
||||
log::error!(
|
||||
"[{}] 未找到 API Key!Provider: {}",
|
||||
@@ -612,34 +527,9 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// anthropic-version 透传:优先使用客户端的版本号
|
||||
// 参考 Claude Code Hub:透传客户端值而非固定版本
|
||||
if let Some(version) = headers.get("anthropic-version") {
|
||||
if let Ok(version_str) = version.to_str() {
|
||||
// 覆盖适配器设置的默认版本
|
||||
request = request.header("anthropic-version", version_str);
|
||||
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
|
||||
log::info!(
|
||||
"[{}] 透传 anthropic-version: {}",
|
||||
adapter.name(),
|
||||
version_str
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 最终发送的 Headers 日志 ==========
|
||||
log::info!(
|
||||
"[{}] ====== 最终发送的 Headers ({}) ======",
|
||||
adapter.name(),
|
||||
passed_headers.len()
|
||||
);
|
||||
for (k, v) in &passed_headers {
|
||||
log::info!("[{}] {}: {}", adapter.name(), k, v);
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
|
||||
let response = request.json(&filtered_body).send().await.map_err(|e| {
|
||||
let response = request.json(&request_body).send().await.map_err(|e| {
|
||||
log::error!("[{}] 请求失败: {}", adapter.name(), e);
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
@@ -673,6 +563,25 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类ProxyError
|
||||
///
|
||||
/// 决定哪些错误应该触发故障转移到下一个 Provider
|
||||
///
|
||||
/// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。
|
||||
/// 只有明确是客户端中断的情况才不重试。
|
||||
fn should_retry_same_provider(&self, error: &ProxyError) -> bool {
|
||||
match error {
|
||||
// 网络类错误:短暂抖动时同一 Provider 内重试有意义
|
||||
ProxyError::Timeout(_) => true,
|
||||
ProxyError::ForwardFailed(_) => true,
|
||||
// 上游 HTTP 错误:只对“可能瞬态”的状态码做同 Provider 重试(其余交给 failover)
|
||||
ProxyError::UpstreamError { status, .. } => {
|
||||
*status == 408 || *status == 429 || *status >= 500
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||
match error {
|
||||
// 网络和上游错误:都应该尝试下一个供应商
|
||||
@@ -688,6 +597,7 @@ impl RequestForwarder {
|
||||
ProxyError::TransformError(_) => ErrorCategory::Retryable,
|
||||
ProxyError::AuthError(_) => ErrorCategory::Retryable,
|
||||
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
|
||||
ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable,
|
||||
// 无可用供应商:所有供应商都试过了,无法重试
|
||||
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
|
||||
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
|
||||
|
||||
@@ -58,10 +58,10 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Codex 智能流式响应模型提取(自动检测格式)
|
||||
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
/// Codex Responses API 流式响应模型提取(优先使用 usage.model)
|
||||
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
|
||||
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
return model;
|
||||
}
|
||||
@@ -76,10 +76,6 @@ fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
// 再回退:从 OpenAI 格式事件中提取
|
||||
events.iter().find_map(|e| e.get("model")?.as_str())
|
||||
})
|
||||
.unwrap_or(request_model)
|
||||
.to_string()
|
||||
}
|
||||
@@ -115,11 +111,11 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
app_type_str: "codex",
|
||||
};
|
||||
|
||||
/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式)
|
||||
/// Codex Responses API 解析配置(用于 /v1/responses)
|
||||
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
stream_parser: TokenUsage::from_codex_stream_events_auto,
|
||||
response_parser: TokenUsage::from_codex_response_auto,
|
||||
model_extractor: codex_auto_model_extractor,
|
||||
stream_parser: TokenUsage::from_codex_stream_events,
|
||||
response_parser: TokenUsage::from_codex_response,
|
||||
model_extractor: codex_model_extractor,
|
||||
app_type_str: "codex",
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::{
|
||||
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
|
||||
ProxyError,
|
||||
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// 流式超时配置
|
||||
@@ -28,7 +26,6 @@ pub struct StreamingTimeoutConfig {
|
||||
/// - 选中的 Provider 列表(用于故障转移)
|
||||
/// - 请求模型名称
|
||||
/// - 日志标签
|
||||
/// - Session ID(用于日志关联)
|
||||
pub struct RequestContext {
|
||||
/// 请求开始时间
|
||||
pub start_time: Instant,
|
||||
@@ -38,7 +35,7 @@ pub struct RequestContext {
|
||||
pub provider: Provider,
|
||||
/// 完整的 Provider 列表(用于故障转移)
|
||||
providers: Vec<Provider>,
|
||||
/// 请求开始时的"当前供应商"(用于判断是否需要同步 UI/托盘)
|
||||
/// 请求开始时的“当前供应商”(用于判断是否需要同步 UI/托盘)
|
||||
///
|
||||
/// 这里使用本地 settings 的设备级 current provider。
|
||||
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
|
||||
@@ -52,8 +49,6 @@ pub struct RequestContext {
|
||||
/// 应用类型(预留,目前通过 app_type_str 使用)
|
||||
#[allow(dead_code)]
|
||||
pub app_type: AppType,
|
||||
/// Session ID(从客户端请求提取或新生成)
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -62,7 +57,6 @@ impl RequestContext {
|
||||
/// # Arguments
|
||||
/// * `state` - 代理服务器状态
|
||||
/// * `body` - 请求体 JSON
|
||||
/// * `headers` - 请求头(用于提取 Session ID)
|
||||
/// * `app_type` - 应用类型
|
||||
/// * `tag` - 日志标签
|
||||
/// * `app_type_str` - 应用类型字符串
|
||||
@@ -72,7 +66,6 @@ impl RequestContext {
|
||||
pub async fn new(
|
||||
state: &ProxyState,
|
||||
body: &serde_json::Value,
|
||||
headers: &HeaderMap,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
@@ -96,31 +89,13 @@ impl RequestContext {
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// 提取 Session ID
|
||||
let session_result = extract_session_id(headers, body, app_type_str);
|
||||
let session_id = session_result.session_id.clone();
|
||||
|
||||
log::debug!(
|
||||
"[{}] Session ID: {} (from {:?}, client_provided: {})",
|
||||
tag,
|
||||
session_id,
|
||||
session_result.source,
|
||||
session_result.client_provided
|
||||
);
|
||||
|
||||
// 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持)
|
||||
// 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额
|
||||
let providers = state
|
||||
.provider_router
|
||||
.select_providers(app_type_str)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
crate::error::AppError::AllProvidersCircuitOpen => {
|
||||
ProxyError::AllProvidersCircuitOpen
|
||||
}
|
||||
crate::error::AppError::NoProvidersConfigured => ProxyError::NoProvidersConfigured,
|
||||
_ => ProxyError::DatabaseError(e.to_string()),
|
||||
})?;
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
|
||||
let provider = providers
|
||||
.first()
|
||||
@@ -128,12 +103,11 @@ impl RequestContext {
|
||||
.ok_or(ProxyError::NoAvailableProvider)?;
|
||||
|
||||
log::info!(
|
||||
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
|
||||
"[{}] Provider: {}, model: {}, failover chain: {} providers",
|
||||
tag,
|
||||
provider.name,
|
||||
request_model,
|
||||
providers.len(),
|
||||
session_id
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
@@ -146,7 +120,6 @@ impl RequestContext {
|
||||
tag,
|
||||
app_type_str,
|
||||
app_type,
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,38 +148,18 @@ impl RequestContext {
|
||||
/// 创建 RequestForwarder
|
||||
///
|
||||
/// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持
|
||||
///
|
||||
/// 配置生效规则:
|
||||
/// - 故障转移开启:超时配置正常生效(0 表示禁用超时)
|
||||
/// - 故障转移关闭:超时配置不生效(全部传入 0)
|
||||
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
|
||||
let (non_streaming_timeout, first_byte_timeout, idle_timeout) =
|
||||
if self.app_config.auto_failover_enabled {
|
||||
// 故障转移开启:使用配置的值(0 = 禁用超时)
|
||||
(
|
||||
self.app_config.non_streaming_timeout as u64,
|
||||
self.app_config.streaming_first_byte_timeout as u64,
|
||||
self.app_config.streaming_idle_timeout as u64,
|
||||
)
|
||||
} else {
|
||||
// 故障转移关闭:不启用超时配置
|
||||
log::info!(
|
||||
"[{}] Failover disabled, timeout configs are bypassed",
|
||||
self.tag
|
||||
);
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
RequestForwarder::new(
|
||||
state.provider_router.clone(),
|
||||
non_streaming_timeout,
|
||||
self.app_config.non_streaming_timeout as u64,
|
||||
self.app_config.max_retries as u8,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
state.failover_manager.clone(),
|
||||
state.app_handle.clone(),
|
||||
self.current_provider_id.clone(),
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.app_config.streaming_first_byte_timeout as u64,
|
||||
self.app_config.streaming_idle_timeout as u64,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -224,24 +177,11 @@ impl RequestContext {
|
||||
}
|
||||
|
||||
/// 获取流式超时配置
|
||||
///
|
||||
/// 配置生效规则:
|
||||
/// - 故障转移开启:返回配置的值(0 表示禁用超时检查)
|
||||
/// - 故障转移关闭:返回 0(禁用超时检查)
|
||||
#[inline]
|
||||
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
|
||||
if self.app_config.auto_failover_enabled {
|
||||
// 故障转移开启:使用配置的值(0 = 禁用超时)
|
||||
StreamingTimeoutConfig {
|
||||
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
|
||||
idle_timeout: self.app_config.streaming_idle_timeout as u64,
|
||||
}
|
||||
} else {
|
||||
// 故障转移关闭:禁用流式超时检查
|
||||
StreamingTimeoutConfig {
|
||||
first_byte_timeout: 0,
|
||||
idle_timeout: 0,
|
||||
}
|
||||
StreamingTimeoutConfig {
|
||||
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
|
||||
idle_timeout: self.app_config.streaming_idle_timeout as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,7 @@ pub async fn handle_messages(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -306,8 +305,7 @@ pub async fn handle_chat_completions(
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -355,8 +353,7 @@ pub async fn handle_responses(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -404,7 +401,7 @@ pub async fn handle_gemini(
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
.with_model_from_uri(&uri);
|
||||
|
||||
@@ -468,7 +465,7 @@ fn log_forward_error(
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
if let Err(e) = logger.log_error_with_context(
|
||||
request_id,
|
||||
request_id.clone(),
|
||||
ctx.provider.id.clone(),
|
||||
ctx.app_type_str.to_string(),
|
||||
ctx.request_model.clone(),
|
||||
@@ -476,7 +473,7 @@ fn log_forward_error(
|
||||
error_message,
|
||||
ctx.latency_ms(),
|
||||
is_streaming,
|
||||
Some(ctx.session_id.clone()),
|
||||
Some(request_id),
|
||||
None,
|
||||
) {
|
||||
log::warn!("记录失败请求日志失败: {e}");
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod body_filter;
|
||||
pub mod circuit_breaker;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
@@ -34,9 +33,7 @@ pub use provider_router::ProviderRouter;
|
||||
#[allow(unused_imports)]
|
||||
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
|
||||
#[allow(unused_imports)]
|
||||
pub use session::{
|
||||
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
|
||||
};
|
||||
pub use session::{ClientFormat, ProxySession};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ impl ProviderRouter {
|
||||
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
let mut total_providers = 0usize;
|
||||
let mut circuit_open_count = 0usize;
|
||||
|
||||
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||
@@ -55,26 +53,18 @@ impl ProviderRouter {
|
||||
if auto_failover_enabled {
|
||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||
total_providers = failover_providers.len();
|
||||
log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)");
|
||||
log::info!(
|
||||
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
|
||||
"[{}] Failover enabled, using queue order ({} items)",
|
||||
app_type,
|
||||
failover_providers.len()
|
||||
);
|
||||
|
||||
for provider in failover_providers {
|
||||
// 检查熔断器状态
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
let state = breaker.get_state().await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::debug!(
|
||||
"[{}] Queue provider available: {} ({}) (state: {:?})",
|
||||
app_type,
|
||||
provider.name,
|
||||
provider.id,
|
||||
state
|
||||
);
|
||||
log::info!(
|
||||
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||
app_type,
|
||||
@@ -84,12 +74,10 @@ impl ProviderRouter {
|
||||
);
|
||||
result.push(provider);
|
||||
} else {
|
||||
circuit_open_count += 1;
|
||||
log::debug!(
|
||||
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
|
||||
"[{}] Queue provider {} circuit breaker open, skipping",
|
||||
app_type,
|
||||
provider.name,
|
||||
state
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,27 +94,15 @@ impl ProviderRouter {
|
||||
current.name,
|
||||
current.id
|
||||
);
|
||||
total_providers = 1;
|
||||
result.push(current);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[{app_type}] Current provider id {current_id} not found in database"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::debug!("[{app_type}] No current provider configured");
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
// 区分两种情况:全部熔断 vs 未配置供应商
|
||||
if total_providers > 0 && circuit_open_count == total_providers {
|
||||
log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道");
|
||||
return Err(AppError::AllProvidersCircuitOpen);
|
||||
} else {
|
||||
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空");
|
||||
return Err(AppError::NoProvidersConfigured);
|
||||
}
|
||||
return Err(AppError::Config(format!(
|
||||
"No available provider for {app_type} (all circuit breakers open or no providers configured)"
|
||||
)));
|
||||
}
|
||||
|
||||
log::info!(
|
||||
|
||||
@@ -112,19 +112,6 @@ pub async fn handle_non_streaming(
|
||||
|
||||
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
|
||||
} else {
|
||||
let model = json_value
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(&ctx.request_model)
|
||||
.to_string();
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
log::debug!(
|
||||
"[{}] 未能解析 usage 信息,跳过记录",
|
||||
parser_config.app_type_str
|
||||
@@ -136,14 +123,6 @@ pub async fn handle_non_streaming(
|
||||
ctx.tag,
|
||||
body_bytes.len()
|
||||
);
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("[{}] ====== 请求结束 ======", ctx.tag);
|
||||
@@ -264,7 +243,6 @@ fn create_usage_collector(
|
||||
let start_time = ctx.start_time;
|
||||
let stream_parser = parser_config.stream_parser;
|
||||
let model_extractor = parser_config.model_extractor;
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||
if let Some(usage) = stream_parser(&events) {
|
||||
@@ -273,7 +251,6 @@ fn create_usage_collector(
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -286,32 +263,10 @@ fn create_usage_collector(
|
||||
first_token_ms,
|
||||
true, // is_streaming
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
TokenUsage::default(),
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true, // is_streaming
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
|
||||
}
|
||||
})
|
||||
@@ -331,7 +286,6 @@ fn spawn_log_usage(
|
||||
let app_type_str = ctx.app_type_str.to_string();
|
||||
let model = model.to_string();
|
||||
let latency_ms = ctx.latency_ms();
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -344,7 +298,6 @@ fn spawn_log_usage(
|
||||
None,
|
||||
is_streaming,
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -362,7 +315,6 @@ async fn log_usage_internal(
|
||||
first_token_ms: Option<u64>,
|
||||
is_streaming: bool,
|
||||
status_code: u16,
|
||||
session_id: Option<String>,
|
||||
) {
|
||||
use super::usage::logger::UsageLogger;
|
||||
|
||||
@@ -386,15 +338,6 @@ async fn log_usage_internal(
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
log::debug!(
|
||||
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
|
||||
session_id.as_deref().unwrap_or("none"),
|
||||
usage.input_tokens,
|
||||
usage.output_tokens,
|
||||
usage.cache_read_tokens,
|
||||
usage.cache_creation_tokens
|
||||
);
|
||||
|
||||
if let Err(e) = logger.log_with_calculation(
|
||||
request_id,
|
||||
provider_id.to_string(),
|
||||
@@ -405,7 +348,7 @@ async fn log_usage_internal(
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
status_code,
|
||||
session_id,
|
||||
None,
|
||||
None, // provider_type
|
||||
is_streaming,
|
||||
) {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
//! Proxy Session - 请求会话管理
|
||||
//!
|
||||
//! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。
|
||||
//!
|
||||
//! ## Session ID 提取
|
||||
//!
|
||||
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
|
||||
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
|
||||
//! - Codex: 从 `previous_response_id` 或 headers 中的 `session_id` 提取
|
||||
//! - 其他: 生成新的 UUID
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -184,179 +176,6 @@ impl ProxySession {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session ID 提取器
|
||||
// ============================================================================
|
||||
|
||||
/// Session ID 来源
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionIdSource {
|
||||
/// 从 metadata.user_id 提取 (Claude)
|
||||
MetadataUserId,
|
||||
/// 从 metadata.session_id 提取
|
||||
MetadataSessionId,
|
||||
/// 从 headers 提取 (Codex)
|
||||
Header,
|
||||
/// 从 previous_response_id 提取 (Codex)
|
||||
PreviousResponseId,
|
||||
/// 新生成
|
||||
Generated,
|
||||
}
|
||||
|
||||
/// Session ID 提取结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionIdResult {
|
||||
/// 提取或生成的 Session ID
|
||||
pub session_id: String,
|
||||
/// Session ID 来源
|
||||
pub source: SessionIdSource,
|
||||
/// 是否为客户端提供的 ID(非新生成)
|
||||
pub client_provided: bool,
|
||||
}
|
||||
|
||||
/// 从请求中提取或生成 Session ID
|
||||
///
|
||||
/// 轻量化实现,仅提取 session_id 用于日志记录,不做复杂的 Session 管理。
|
||||
///
|
||||
/// ## 提取优先级
|
||||
///
|
||||
/// ### Claude 请求
|
||||
/// 1. `metadata.user_id` (格式: `user_xxx_session_yyy`) → 提取 `yyy` 部分
|
||||
/// 2. `metadata.session_id` → 直接使用
|
||||
/// 3. 生成新 UUID
|
||||
///
|
||||
/// ### Codex 请求
|
||||
/// 1. Headers: `session_id` 或 `x-session-id`
|
||||
/// 2. `metadata.session_id`
|
||||
/// 3. `previous_response_id` (对话延续)
|
||||
/// 4. 生成新 UUID
|
||||
///
|
||||
/// ## 示例
|
||||
///
|
||||
/// ```ignore
|
||||
/// let result = extract_session_id(&headers, &body, "claude");
|
||||
/// println!("Session ID: {} (from {:?})", result.session_id, result.source);
|
||||
/// ```
|
||||
pub fn extract_session_id(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
client_format: &str,
|
||||
) -> SessionIdResult {
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Claude 请求:从 metadata 提取
|
||||
if let Some(result) = extract_from_metadata(body) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 兜底:生成新 Session ID
|
||||
generate_new_session_id()
|
||||
}
|
||||
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
for header_name in &["session_id", "x-session-id"] {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
// Codex Session ID 通常较长(UUID 格式)
|
||||
if session_id.len() > 20 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 从 body.metadata.session_id 提取
|
||||
if let Some(session_id) = body
|
||||
.get("metadata")
|
||||
.and_then(|m| m.get("session_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if session_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{session_id}"),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从 previous_response_id 提取(对话延续)
|
||||
if let Some(prev_id) = body.get("previous_response_id").and_then(|v| v.as_str()) {
|
||||
if prev_id.len() > 10 {
|
||||
return Some(SessionIdResult {
|
||||
session_id: format!("codex_{prev_id}"),
|
||||
source: SessionIdSource::PreviousResponseId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 metadata 提取 Session ID (Claude)
|
||||
fn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
let metadata = body.get("metadata")?;
|
||||
|
||||
// 1. 从 metadata.user_id 提取(格式: user_xxx_session_yyy)
|
||||
if let Some(user_id) = metadata.get("user_id").and_then(|v| v.as_str()) {
|
||||
if let Some(session_id) = parse_session_from_user_id(user_id) {
|
||||
return Some(SessionIdResult {
|
||||
session_id,
|
||||
source: SessionIdSource::MetadataUserId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 直接从 metadata.session_id 提取
|
||||
if let Some(session_id) = metadata.get("session_id").and_then(|v| v.as_str()) {
|
||||
if !session_id.is_empty() {
|
||||
return Some(SessionIdResult {
|
||||
session_id: session_id.to_string(),
|
||||
source: SessionIdSource::MetadataSessionId,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 user_id 解析 session_id
|
||||
///
|
||||
/// 格式: `user_identifier_session_actual_session_id`
|
||||
fn parse_session_from_user_id(user_id: &str) -> Option<String> {
|
||||
// 查找 "_session_" 分隔符
|
||||
if let Some(pos) = user_id.find("_session_") {
|
||||
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
|
||||
if !session_id.is_empty() {
|
||||
return Some(session_id.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 生成新的 Session ID
|
||||
fn generate_new_session_id() -> SessionIdResult {
|
||||
SessionIdResult {
|
||||
session_id: Uuid::new_v4().to_string(),
|
||||
source: SessionIdSource::Generated,
|
||||
client_provided: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -476,92 +295,4 @@ mod tests {
|
||||
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
|
||||
}
|
||||
|
||||
// ========== Session ID 提取测试 ==========
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_claude_metadata_user_id() {
|
||||
let headers = HeaderMap::new();
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {
|
||||
"user_id": "user_john_doe_session_abc123def456"
|
||||
}
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "claude");
|
||||
|
||||
assert_eq!(result.session_id, "abc123def456");
|
||||
assert_eq!(result.source, SessionIdSource::MetadataUserId);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_claude_metadata_session_id() {
|
||||
let headers = HeaderMap::new();
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {
|
||||
"session_id": "my-session-123"
|
||||
}
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "claude");
|
||||
|
||||
assert_eq!(result.session_id, "my-session-123");
|
||||
assert_eq!(result.source, SessionIdSource::MetadataSessionId);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_codex_previous_response_id() {
|
||||
let headers = HeaderMap::new();
|
||||
let body = json!({
|
||||
"input": "Write a function",
|
||||
"previous_response_id": "resp_abc123def456789"
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "codex");
|
||||
|
||||
assert_eq!(result.session_id, "codex_resp_abc123def456789");
|
||||
assert_eq!(result.source, SessionIdSource::PreviousResponseId);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_generates_new_when_not_found() {
|
||||
let headers = HeaderMap::new();
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "claude");
|
||||
|
||||
assert!(!result.session_id.is_empty());
|
||||
assert_eq!(result.source, SessionIdSource::Generated);
|
||||
assert!(!result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_session_from_user_id() {
|
||||
assert_eq!(
|
||||
parse_session_from_user_id("user_john_session_abc123"),
|
||||
Some("abc123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_session_from_user_id("my_app_session_xyz789"),
|
||||
Some("xyz789".to_string())
|
||||
);
|
||||
// 注意: "_session_" 是分隔符,所以下面的字符串会匹配
|
||||
assert_eq!(
|
||||
parse_session_from_user_id("no_session_marker"),
|
||||
Some("marker".to_string())
|
||||
);
|
||||
// 没有 "_session_" 分隔符的情况
|
||||
assert_eq!(parse_session_from_user_id("user_john_abc123"), None);
|
||||
assert_eq!(parse_session_from_user_id("_session_"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +35,6 @@ impl CostCalculator {
|
||||
/// - `usage`: Token 使用量
|
||||
/// - `pricing`: 模型定价
|
||||
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
|
||||
///
|
||||
/// # 计算逻辑
|
||||
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
||||
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
||||
/// - 这样避免缓存部分被重复计费
|
||||
pub fn calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
@@ -47,10 +42,7 @@ impl CostCalculator {
|
||||
) -> CostBreakdown {
|
||||
let million = Decimal::from(1_000_000);
|
||||
|
||||
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
||||
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
||||
|
||||
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
|
||||
let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million
|
||||
/ million
|
||||
* cost_multiplier;
|
||||
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
|
||||
@@ -121,8 +113,8 @@ mod tests {
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
|
||||
// input: 1000 * 3.0 / 1M = 0.003
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||
// output: 500 * 15.0 / 1M = 0.0075
|
||||
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
|
||||
// cache_read: 200 * 0.3 / 1M = 0.00006
|
||||
@@ -132,8 +124,8 @@ mod tests {
|
||||
cost.cache_creation_cost,
|
||||
Decimal::from_str("0.000375").unwrap()
|
||||
);
|
||||
// total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
|
||||
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -163,21 +163,13 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let cached_tokens = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
Some(Self {
|
||||
input_tokens: input_tokens? as u32,
|
||||
output_tokens: output_tokens? as u32,
|
||||
cache_read_tokens: cached_tokens,
|
||||
cache_read_tokens: usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
cache_creation_tokens: usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -196,27 +188,16 @@ impl TokenUsage {
|
||||
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
|
||||
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
|
||||
|
||||
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
|
||||
// 获取 cached_tokens (可能在 input_tokens_details 中)
|
||||
let cached_tokens = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.get("input_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| {
|
||||
usage
|
||||
.get("input_tokens_details")
|
||||
.and_then(|d| d.get("cached_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 调整 input_tokens: 减去 cached_tokens
|
||||
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
|
||||
|
||||
// 提取响应中的模型名称
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Some(Self {
|
||||
input_tokens: adjusted_input,
|
||||
output_tokens,
|
||||
@@ -225,7 +206,7 @@ impl TokenUsage {
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
model,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -239,7 +220,7 @@ impl TokenUsage {
|
||||
if event_type == "response.completed" {
|
||||
if let Some(response) = event.get("response") {
|
||||
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
|
||||
return Self::from_codex_response_adjusted(response);
|
||||
return Self::from_codex_response(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,51 +229,6 @@ impl TokenUsage {
|
||||
None
|
||||
}
|
||||
|
||||
/// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式
|
||||
///
|
||||
/// Codex 支持两种 API 格式:
|
||||
/// - `/v1/responses`: 使用 input_tokens/output_tokens
|
||||
/// - `/v1/chat/completions`: 使用 prompt_tokens/completion_tokens (OpenAI 格式)
|
||||
///
|
||||
/// 注意:记录原始 input_tokens,费用计算时再减去 cached_tokens
|
||||
pub fn from_codex_response_auto(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
|
||||
// 检测格式:OpenAI 使用 prompt_tokens,Codex 使用 input_tokens
|
||||
if usage.get("prompt_tokens").is_some() {
|
||||
log::debug!("[Codex] 检测到 OpenAI 格式 (prompt_tokens)");
|
||||
Self::from_openai_response(body)
|
||||
} else if usage.get("input_tokens").is_some() {
|
||||
log::debug!("[Codex] 检测到 Codex 格式 (input_tokens)");
|
||||
// 使用非调整版本,记录原始 input_tokens
|
||||
Self::from_codex_response(body)
|
||||
} else {
|
||||
log::debug!("[Codex] 无法识别响应格式,usage: {usage:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能 Codex 流式响应解析 - 自动检测 OpenAI 或 Codex 格式
|
||||
pub fn from_codex_stream_events_auto(events: &[Value]) -> Option<Self> {
|
||||
log::debug!("[Codex] 智能解析流式事件,共 {} 个事件", events.len());
|
||||
|
||||
// 先尝试 Codex Responses API 格式 (response.completed 事件)
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
if event_type == "response.completed" {
|
||||
if let Some(response) = event.get("response") {
|
||||
log::debug!("[Codex] 找到 response.completed 事件");
|
||||
return Self::from_codex_response_auto(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到 OpenAI Chat Completions 格式 (最后一个 chunk 包含 usage)
|
||||
log::debug!("[Codex] 尝试 OpenAI 流式格式");
|
||||
Self::from_openai_stream_events(events)
|
||||
}
|
||||
|
||||
/// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)
|
||||
pub fn from_openai_response(body: &Value) -> Option<Self> {
|
||||
let usage = body.get("usage")?;
|
||||
@@ -348,16 +284,9 @@ impl TokenUsage {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let prompt_tokens = usage.get("promptTokenCount")?.as_u64()? as u32;
|
||||
let total_tokens = usage.get("totalTokenCount")?.as_u64()? as u32;
|
||||
|
||||
// 输出 tokens = 总 tokens - 输入 tokens
|
||||
// 这包含了 candidatesTokenCount + thoughtsTokenCount
|
||||
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
|
||||
|
||||
Some(Self {
|
||||
input_tokens: prompt_tokens,
|
||||
output_tokens,
|
||||
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
|
||||
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
|
||||
cache_read_tokens: usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -371,25 +300,20 @@ impl TokenUsage {
|
||||
#[allow(dead_code)]
|
||||
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
|
||||
let mut total_input = 0u32;
|
||||
let mut total_tokens = 0u32;
|
||||
let mut total_output = 0u32;
|
||||
let mut total_cache_read = 0u32;
|
||||
let mut model: Option<String> = None;
|
||||
|
||||
for chunk in chunks {
|
||||
if let Some(usage) = chunk.get("usageMetadata") {
|
||||
// 输入 tokens (通常在所有 chunk 中保持不变)
|
||||
total_input = usage
|
||||
.get("promptTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 总 tokens (包含输入 + 输出 + 思考)
|
||||
total_tokens = usage
|
||||
.get("totalTokenCount")
|
||||
total_output += usage
|
||||
.get("candidatesTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 缓存读取 tokens
|
||||
total_cache_read = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -404,9 +328,6 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
// 输出 tokens = 总 tokens - 输入 tokens
|
||||
let total_output = total_tokens.saturating_sub(total_input);
|
||||
|
||||
if total_input > 0 || total_output > 0 {
|
||||
Some(Self {
|
||||
input_tokens: total_input,
|
||||
@@ -545,18 +466,15 @@ mod tests {
|
||||
let response = json!({
|
||||
"modelVersion": "gemini-3-pro-high",
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 8383,
|
||||
"promptTokenCount": 100,
|
||||
"candidatesTokenCount": 50,
|
||||
"thoughtsTokenCount": 114,
|
||||
"totalTokenCount": 8547,
|
||||
"cachedContentTokenCount": 20
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 8383);
|
||||
// output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164
|
||||
assert_eq!(usage.output_tokens, 164);
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
|
||||
@@ -568,78 +486,19 @@ mod tests {
|
||||
let response = json!({
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 100,
|
||||
"totalTokenCount": 150,
|
||||
"candidatesTokenCount": 50,
|
||||
"cachedContentTokenCount": 20
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
// output_tokens = totalTokenCount - promptTokenCount = 150 - 100 = 50
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gemini_response_with_thoughts() {
|
||||
// 测试包含 thoughtsTokenCount 的实际响应
|
||||
// 这是用户报告的真实场景
|
||||
let response = json!({
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": "",
|
||||
"thoughtSignature": "EvcECvQE..."
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
],
|
||||
"modelVersion": "gemini-3-pro-high",
|
||||
"responseId": "yupTafqLDu-PjMcPhrOx4QQ",
|
||||
"usageMetadata": {
|
||||
"candidatesTokenCount": 50,
|
||||
"promptTokenCount": 8383,
|
||||
"thoughtsTokenCount": 114,
|
||||
"totalTokenCount": 8547
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_gemini_response(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 8383);
|
||||
// output_tokens = totalTokenCount - promptTokenCount
|
||||
// = 8547 - 8383 = 164 (包含 candidatesTokenCount 50 + thoughtsTokenCount 114)
|
||||
assert_eq!(usage.output_tokens, 164);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_parsing_cached_tokens_in_details() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 300
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response(&response).unwrap();
|
||||
// 非调整模式:input_tokens 保持原值,但应记录缓存命中
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted() {
|
||||
let response = json!({
|
||||
@@ -675,22 +534,6 @@ mod tests {
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_cache_read_input_tokens() {
|
||||
let response = json!({
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"cache_read_input_tokens": 200
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 800);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_adjusted_saturating_sub() {
|
||||
// 测试 cached_tokens > input_tokens 的边界情况
|
||||
@@ -772,110 +615,4 @@ mod tests {
|
||||
assert_eq!(usage.cache_read_tokens, 50);
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 智能 Codex 解析测试
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_auto_openai_format() {
|
||||
// OpenAI 格式 (prompt_tokens/completion_tokens)
|
||||
let response = json!({
|
||||
"model": "gpt-4o",
|
||||
"usage": {
|
||||
"prompt_tokens": 1000,
|
||||
"completion_tokens": 500,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 200
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_auto(&response).unwrap();
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
assert_eq!(usage.model, Some("gpt-4o".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_auto_codex_format() {
|
||||
// Codex 格式 (input_tokens/output_tokens)
|
||||
let response = json!({
|
||||
"model": "o3",
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 300
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let usage = TokenUsage::from_codex_response_auto(&response).unwrap();
|
||||
// 记录原始 input_tokens,不调整
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 300);
|
||||
assert_eq!(usage.model, Some("o3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_stream_events_auto_codex_format() {
|
||||
// Codex Responses API 流式格式 (response.completed 事件)
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_123"
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"model": "o3",
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();
|
||||
// 记录原始 input_tokens,不调整
|
||||
assert_eq!(usage.input_tokens, 1000);
|
||||
assert_eq!(usage.output_tokens, 500);
|
||||
assert_eq!(usage.cache_read_tokens, 200);
|
||||
assert_eq!(usage.model, Some("o3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_stream_events_auto_openai_format() {
|
||||
// OpenAI Chat Completions 流式格式 (最后一个 chunk 包含 usage)
|
||||
let events = vec![
|
||||
json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4o",
|
||||
"choices": [{"delta": {"content": "Hello"}}]
|
||||
}),
|
||||
json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4o",
|
||||
"choices": [{"delta": {}}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.model, Some("gpt-4o".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +146,7 @@ impl ConfigService {
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||
crate::mcp::sync_enabled_to_codex(config)?;
|
||||
|
||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
||||
|
||||
@@ -217,12 +217,9 @@ impl ProviderService {
|
||||
.flatten()
|
||||
.is_some();
|
||||
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
|
||||
let live_taken_over = state
|
||||
.proxy_service
|
||||
.detect_takeover_in_live_config_for_app(&app_type);
|
||||
|
||||
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
|
||||
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
|
||||
let should_hot_switch = is_app_taken_over && is_proxy_running;
|
||||
|
||||
if should_hot_switch {
|
||||
// Proxy takeover mode: hot-switch only, don't write Live config
|
||||
@@ -693,140 +690,3 @@ pub struct ProviderSortUpdate {
|
||||
#[serde(rename = "sortIndex")]
|
||||
pub sort_index: usize,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一供应商(Universal Provider)服务方法
|
||||
// ============================================================================
|
||||
|
||||
use crate::provider::UniversalProvider;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl ProviderService {
|
||||
/// 获取所有统一供应商
|
||||
pub fn list_universal(
|
||||
state: &AppState,
|
||||
) -> Result<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ impl ProxyService {
|
||||
self.start().await?;
|
||||
}
|
||||
|
||||
// 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管
|
||||
// 2) 已接管则直接返回(幂等)
|
||||
let current_config = self
|
||||
.db
|
||||
.get_proxy_config_for_app(app_type_str)
|
||||
@@ -201,22 +201,7 @@ impl ProxyService {
|
||||
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||
|
||||
if current_config.enabled {
|
||||
let has_backup = match self.db.get_live_backup(app_type_str).await {
|
||||
Ok(v) => v.is_some(),
|
||||
Err(e) => {
|
||||
log::warn!("读取 {app_type_str} 备份失败(将继续重建接管): {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);
|
||||
|
||||
if has_backup || live_taken_over {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3) 备份 Live 配置(严格:目标 app 不存在则报错)
|
||||
@@ -1078,7 +1063,7 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
|
||||
fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
|
||||
match app_type {
|
||||
AppType::Claude => match self.read_claude_live() {
|
||||
Ok(config) => Self::is_claude_live_taken_over(&config),
|
||||
@@ -1272,8 +1257,10 @@ impl ProxyService {
|
||||
|
||||
/// 检查是否处于 Live 接管模式
|
||||
pub async fn is_takeover_active(&self) -> Result<bool, String> {
|
||||
let status = self.get_takeover_status().await?;
|
||||
Ok(status.claude || status.codex || status.gemini)
|
||||
self.db
|
||||
.is_live_takeover_active()
|
||||
.await
|
||||
.map_err(|e| format!("检查接管状态失败: {e}"))
|
||||
}
|
||||
|
||||
/// 从异常退出中恢复(启动时调用)
|
||||
|
||||
@@ -77,7 +77,7 @@ impl Default for SkillStore {
|
||||
SkillRepo {
|
||||
owner: "ComposioHQ".to_string(),
|
||||
name: "awesome-claude-skills".to_string(),
|
||||
branch: "master".to_string(),
|
||||
branch: "main".to_string(),
|
||||
enabled: true,
|
||||
},
|
||||
SkillRepo {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use chrono::{Local, TimeZone};
|
||||
use chrono::{Duration, Utc};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -181,63 +181,29 @@ impl Database {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致)
|
||||
pub fn get_daily_trends(
|
||||
&self,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
/// 获取每日趋势
|
||||
pub fn get_daily_trends(&self, days: u32) -> Result<Vec<DailyStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp());
|
||||
let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60);
|
||||
if days <= 1 {
|
||||
let sql = "SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00Z', datetime(created_at, 'unixepoch')) as bucket,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||
FROM proxy_request_logs
|
||||
WHERE created_at >= strftime('%s', 'now', '-1 day')
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC";
|
||||
|
||||
if start_ts >= end_ts {
|
||||
start_ts = end_ts - 24 * 60 * 60;
|
||||
}
|
||||
|
||||
let duration = end_ts - start_ts;
|
||||
let bucket_seconds: i64 = if duration <= 24 * 60 * 60 {
|
||||
60 * 60
|
||||
} else {
|
||||
24 * 60 * 60
|
||||
};
|
||||
let mut bucket_count: i64 = if duration <= 0 {
|
||||
1
|
||||
} else {
|
||||
((duration as f64) / bucket_seconds as f64).ceil() as i64
|
||||
};
|
||||
|
||||
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
|
||||
if bucket_seconds == 60 * 60 {
|
||||
bucket_count = 24;
|
||||
}
|
||||
|
||||
if bucket_count < 1 {
|
||||
bucket_count = 1;
|
||||
}
|
||||
|
||||
let sql = "
|
||||
SELECT
|
||||
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||
FROM proxy_request_logs
|
||||
WHERE created_at >= ?1 AND created_at <= ?2
|
||||
GROUP BY bucket_idx
|
||||
ORDER BY bucket_idx ASC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
DailyStats {
|
||||
date: String::new(),
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(DailyStats {
|
||||
date: row.get(0)?,
|
||||
request_count: row.get::<_, i64>(1)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
|
||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
||||
@@ -245,50 +211,99 @@ impl Database {
|
||||
total_output_tokens: row.get::<_, i64>(5)? as u64,
|
||||
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut map: HashMap<i64, DailyStats> = HashMap::new();
|
||||
for row in rows {
|
||||
let (mut bucket_idx, stat) = row?;
|
||||
if bucket_idx < 0 {
|
||||
continue;
|
||||
let mut buckets: HashMap<String, DailyStats> = HashMap::new();
|
||||
for row in rows {
|
||||
let stat = row?;
|
||||
buckets.insert(stat.date.clone(), stat);
|
||||
}
|
||||
if bucket_idx >= bucket_count {
|
||||
bucket_idx = bucket_count - 1;
|
||||
|
||||
let mut stats = Vec::new();
|
||||
let today = Utc::now().date_naive();
|
||||
for hour in 0..24 {
|
||||
let bucket = today
|
||||
.and_hms_opt(hour, 0, 0)
|
||||
.unwrap()
|
||||
.format("%Y-%m-%dT%H:00:00Z")
|
||||
.to_string();
|
||||
|
||||
if let Some(stat) = buckets.remove(&bucket) {
|
||||
stats.push(stat);
|
||||
} else {
|
||||
stats.push(DailyStats {
|
||||
date: bucket,
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
map.insert(bucket_idx, stat);
|
||||
Ok(stats)
|
||||
} else {
|
||||
let sql = "SELECT
|
||||
date(created_at, 'unixepoch') as bucket,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||
FROM proxy_request_logs
|
||||
WHERE created_at >= strftime('%s', 'now', ?)
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC";
|
||||
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map([format!("-{days} days")], |row| {
|
||||
Ok(DailyStats {
|
||||
date: row.get(0)?,
|
||||
request_count: row.get::<_, i64>(1)? as u64,
|
||||
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
|
||||
total_tokens: row.get::<_, i64>(3)? as u64,
|
||||
total_input_tokens: row.get::<_, i64>(4)? as u64,
|
||||
total_output_tokens: row.get::<_, i64>(5)? as u64,
|
||||
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let stat = row?;
|
||||
map.insert(stat.date.clone(), stat);
|
||||
}
|
||||
|
||||
let mut stats = Vec::new();
|
||||
let start_day =
|
||||
Utc::now().date_naive() - Duration::days((days.saturating_sub(1)) as i64);
|
||||
|
||||
for i in 0..days {
|
||||
let day = start_day + Duration::days(i as i64);
|
||||
let key = day.format("%Y-%m-%d").to_string();
|
||||
if let Some(stat) = map.remove(&key) {
|
||||
stats.push(stat);
|
||||
} else {
|
||||
stats.push(DailyStats {
|
||||
date: key,
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
let mut stats = Vec::with_capacity(bucket_count as usize);
|
||||
for i in 0..bucket_count {
|
||||
let bucket_start_ts = start_ts + i * bucket_seconds;
|
||||
let bucket_start = Local
|
||||
.timestamp_opt(bucket_start_ts, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Local::now);
|
||||
|
||||
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
|
||||
|
||||
if let Some(mut stat) = map.remove(&i) {
|
||||
stat.date = date;
|
||||
stats.push(stat);
|
||||
} else {
|
||||
stats.push(DailyStats {
|
||||
date,
|
||||
request_count: 0,
|
||||
total_cost: "0.000000".to_string(),
|
||||
total_tokens: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
total_cache_creation_tokens: 0,
|
||||
total_cache_read_tokens: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
@@ -602,7 +617,7 @@ impl Database {
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')",
|
||||
AND date(created_at, 'unixepoch') = date('now')",
|
||||
params![provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -614,7 +629,7 @@ impl Database {
|
||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM proxy_request_logs
|
||||
WHERE provider_id = ? AND app_type = ?
|
||||
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')",
|
||||
AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')",
|
||||
params![provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
@@ -798,46 +813,89 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// 标准化模型名称:去除供应商前缀并将点号替换为短横线
|
||||
/// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
|
||||
fn normalize_model_id(model_id: &str) -> String {
|
||||
// 1. 去除供应商前缀(如 anthropic/、openai/)
|
||||
let stripped = if let Some(pos) = model_id.find('/') {
|
||||
&model_id[pos + 1..]
|
||||
} else {
|
||||
model_id
|
||||
};
|
||||
// 2. 将点号替换为短横线(如 claude-haiku-4.5 → claude-haiku-4-5)
|
||||
stripped.replace('.', "-")
|
||||
}
|
||||
|
||||
pub(crate) fn find_model_pricing_row(
|
||||
conn: &Connection,
|
||||
model_id: &str,
|
||||
) -> Result<Option<(String, String, String, String)>, AppError> {
|
||||
// 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905
|
||||
let without_prefix = model_id
|
||||
.rsplit_once('/')
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(model_id);
|
||||
let cleaned = without_prefix
|
||||
.split(':')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or(without_prefix);
|
||||
// 0. 标准化模型名称(去除前缀 + 点号转短横线)
|
||||
// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
|
||||
let normalized = normalize_model_id(model_id);
|
||||
|
||||
// 2) 精确匹配清洗后的名称
|
||||
let exact = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
WHERE model_id = ?1",
|
||||
[cleaned],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
|
||||
// 1. 精确匹配(先尝试原始名称,再尝试标准化后的名称)
|
||||
for id in [model_id, normalized.as_str()] {
|
||||
let exact = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
WHERE model_id = ?1",
|
||||
[id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
|
||||
|
||||
if exact.is_none() {
|
||||
log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0");
|
||||
if exact.is_some() {
|
||||
if id != model_id {
|
||||
log::info!("模型 {model_id} 标准化后精确匹配到: {id}");
|
||||
}
|
||||
return Ok(exact);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(exact)
|
||||
// 2. 逐步删除后缀匹配(claude-haiku-4-5-20250929 → claude-haiku-4-5 → claude-haiku-4 → claude-haiku)
|
||||
// 使用标准化后的名称进行后缀匹配
|
||||
let mut current = normalized;
|
||||
while let Some(pos) = current.rfind('-') {
|
||||
current = current[..pos].to_string();
|
||||
|
||||
let result = conn
|
||||
.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing
|
||||
WHERE model_id = ?1",
|
||||
[¤t],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
|
||||
|
||||
if result.is_some() {
|
||||
log::info!("模型 {model_id} 通过删除后缀匹配到: {current}");
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("模型 {model_id} 未找到定价信息,成本将记录为 0");
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -917,39 +975,54 @@ mod tests {
|
||||
let db = Database::memory()?;
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 准备额外定价数据,覆盖前缀/后缀清洗场景
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
"claude-haiku-4.5",
|
||||
"Claude Haiku 4.5",
|
||||
"1.0",
|
||||
"2.0",
|
||||
"0.0",
|
||||
"0.0"
|
||||
],
|
||||
)?;
|
||||
// 测试精确匹配
|
||||
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5")?;
|
||||
assert!(result.is_some(), "应该能精确匹配 claude-sonnet-4-5");
|
||||
|
||||
// 测试精确匹配(seed_model_pricing 已预置 claude-sonnet-4-5-20250929)
|
||||
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能精确匹配 claude-sonnet-4-5-20250929"
|
||||
);
|
||||
|
||||
// 清洗:去除前缀和冒号后缀
|
||||
// 测试带供应商前缀的模型名称(anthropic/claude-haiku-4.5 → claude-haiku-4-5)
|
||||
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5"
|
||||
"应该能匹配带前缀的模型 anthropic/claude-haiku-4.5"
|
||||
);
|
||||
let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?;
|
||||
|
||||
// 测试带供应商前缀 + 点号的模型名称
|
||||
let result = find_model_pricing_row(&conn, "anthropic/claude-sonnet-4.5")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
|
||||
"应该能匹配带前缀的模型 anthropic/claude-sonnet-4.5"
|
||||
);
|
||||
|
||||
// 测试逐步删除后缀匹配 - 日期后缀
|
||||
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20241022")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 claude-sonnet-4-5-20241022"
|
||||
);
|
||||
|
||||
// 测试逐步删除后缀匹配 - 多个后缀
|
||||
let result = find_model_pricing_row(&conn, "claude-haiku-4-5-20240229-preview")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 claude-haiku-4-5-20240229-preview"
|
||||
);
|
||||
|
||||
// 测试 GPT 模型
|
||||
let result = find_model_pricing_row(&conn, "gpt-5-2024-11-20")?;
|
||||
assert!(result.is_some(), "应该能通过删除后缀匹配 gpt-5-2024-11-20");
|
||||
|
||||
// 测试 Gemini 模型
|
||||
let result = find_model_pricing_row(&conn, "gemini-2.5-flash-exp")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 gemini-2.5-flash-exp"
|
||||
);
|
||||
|
||||
// 测试 claude-sonnet-4-5 命名格式
|
||||
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"应该能通过删除后缀匹配 claude-sonnet-4-5-20250929"
|
||||
);
|
||||
|
||||
// 测试不存在的模型
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.9.0-3",
|
||||
"version": "3.9.0-2",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -76,8 +76,19 @@ fn sync_codex_provider_writes_auth_and_config() {
|
||||
|
||||
let mut config = MultiAppConfig::default();
|
||||
|
||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
||||
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
|
||||
config.mcp.codex.servers.insert(
|
||||
"echo-server".into(),
|
||||
json!({
|
||||
"id": "echo-server",
|
||||
"enabled": true,
|
||||
"server": {
|
||||
"type": "stdio",
|
||||
"command": "echo",
|
||||
"args": ["hello"]
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let provider_config = json!({
|
||||
"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");
|
||||
// 验证基础配置正确写入
|
||||
assert!(
|
||||
toml_text.contains("base_url"),
|
||||
"config.toml should contain base_url from provider config"
|
||||
toml_text.contains("command = \"echo\""),
|
||||
"config.toml should contain serialized enabled MCP server"
|
||||
);
|
||||
|
||||
// 当前供应商应同步最新 config 文本
|
||||
|
||||
@@ -49,7 +49,6 @@ pub fn test_mutex() -> &'static Mutex<()> {
|
||||
}
|
||||
|
||||
/// 创建测试用的 AppState,包含一个空的数据库
|
||||
#[allow(dead_code)]
|
||||
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
||||
let db = Arc::new(Database::init()?);
|
||||
let proxy_service = ProxyService::new(db.clone());
|
||||
@@ -57,7 +56,6 @@ pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
|
||||
#[allow(dead_code)]
|
||||
pub fn create_test_state_with_config(
|
||||
config: &MultiAppConfig,
|
||||
) -> Result<AppState, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||
@@ -44,17 +43,9 @@ import PromptPanel from "@/components/prompts/PromptPanel";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { AgentsPanel } from "@/components/agents/AgentsPanel";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type View =
|
||||
| "providers"
|
||||
| "settings"
|
||||
| "prompts"
|
||||
| "skills"
|
||||
| "mcp"
|
||||
| "agents"
|
||||
| "universal";
|
||||
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
||||
|
||||
const DRAG_BAR_HEIGHT = 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
@@ -74,9 +65,21 @@ function App() {
|
||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||
|
||||
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
|
||||
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||
// 保存最后一个有效的 provider,用于动画退出期间显示内容
|
||||
const lastUsageProviderRef = useRef<Provider | null>(null);
|
||||
const lastEditingProviderRef = useRef<Provider | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (usageProvider) {
|
||||
lastUsageProviderRef.current = usageProvider;
|
||||
}
|
||||
}, [usageProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProvider) {
|
||||
lastEditingProviderRef.current = editingProvider;
|
||||
}
|
||||
}, [editingProvider]);
|
||||
|
||||
const promptPanelRef = useRef<any>(null);
|
||||
const mcpPanelRef = useRef<any>(null);
|
||||
@@ -142,38 +145,6 @@ function App() {
|
||||
};
|
||||
}, [activeApp, refetch]);
|
||||
|
||||
// 监听统一供应商同步事件,刷新所有应用的供应商列表
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unsubscribe = await listen("universal-provider-synced", async () => {
|
||||
// 统一供应商同步后刷新所有应用的供应商列表
|
||||
// 使用 invalidateQueries 使所有 providers 查询失效
|
||||
await queryClient.invalidateQueries({ queryKey: ["providers"] });
|
||||
// 同时更新托盘菜单
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to update tray menu", error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[App] Failed to subscribe universal-provider-synced event",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setupListener();
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// 应用启动时检测所有应用的环境变量冲突
|
||||
useEffect(() => {
|
||||
const checkEnvOnStartup = async () => {
|
||||
@@ -251,21 +222,6 @@ function App() {
|
||||
checkEnvOnSwitch();
|
||||
}, [activeApp]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setCurrentView("settings");
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleGlobalShortcut);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleGlobalShortcut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 打开网站链接
|
||||
const handleOpenWebsite = async (url: string) => {
|
||||
try {
|
||||
@@ -408,12 +364,6 @@ function App() {
|
||||
return (
|
||||
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||
);
|
||||
case "universal":
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 pt-4">
|
||||
<UniversalProviderPanel />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
@@ -544,10 +494,6 @@ function App() {
|
||||
{currentView === "skills" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
{currentView === "agents" && t("agents.title")}
|
||||
{currentView === "universal" &&
|
||||
t("universalProvider.title", {
|
||||
defaultValue: "统一供应商",
|
||||
})}
|
||||
</h1>
|
||||
</div>
|
||||
) : (
|
||||
@@ -707,7 +653,7 @@ function App() {
|
||||
|
||||
<EditProviderDialog
|
||||
open={Boolean(editingProvider)}
|
||||
provider={effectiveEditingProvider}
|
||||
provider={lastEditingProviderRef.current}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingProvider(null);
|
||||
@@ -718,9 +664,9 @@ function App() {
|
||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||
/>
|
||||
|
||||
{effectiveUsageProvider && (
|
||||
{lastUsageProviderRef.current && (
|
||||
<UsageScriptModal
|
||||
provider={effectiveUsageProvider}
|
||||
provider={lastUsageProviderRef.current}
|
||||
appId={activeApp}
|
||||
isOpen={Boolean(usageProvider)}
|
||||
onClose={() => setUsageProvider(null)}
|
||||
|
||||
@@ -191,11 +191,11 @@ export function EnvWarningBanner({
|
||||
<div className="flex-1 min-w-0">
|
||||
<label
|
||||
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}
|
||||
</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}
|
||||
</p>
|
||||
<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">
|
||||
{/* Hint */}
|
||||
<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")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -248,7 +248,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
<div className="space-y-4 min-h-[400px]">
|
||||
{/* Type */}
|
||||
<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>
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
@@ -262,7 +262,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
}
|
||||
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")}
|
||||
</span>
|
||||
</label>
|
||||
@@ -276,7 +276,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
}
|
||||
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")}
|
||||
</span>
|
||||
</label>
|
||||
@@ -290,7 +290,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
}
|
||||
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")}
|
||||
</span>
|
||||
</label>
|
||||
@@ -299,7 +299,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
|
||||
{/* Title */}
|
||||
<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>
|
||||
</label>
|
||||
<Input
|
||||
@@ -317,7 +317,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
<>
|
||||
{/* Command */}
|
||||
<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")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
@@ -333,7 +333,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
|
||||
{/* Args */}
|
||||
<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")}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -341,13 +341,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) => setWizardArgs(e.target.value)}
|
||||
placeholder={t("mcp.wizard.argsPlaceholder")}
|
||||
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>
|
||||
|
||||
{/* Env */}
|
||||
<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")}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -355,7 +355,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) => setWizardEnv(e.target.value)}
|
||||
placeholder={t("mcp.wizard.envPlaceholder")}
|
||||
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>
|
||||
</>
|
||||
@@ -366,7 +366,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
<>
|
||||
{/* URL */}
|
||||
<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")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
@@ -382,7 +382,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
|
||||
{/* Headers */}
|
||||
<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")}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -390,7 +390,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
onChange={(e) => setWizardHeaders(e.target.value)}
|
||||
placeholder={t("mcp.wizard.headersPlaceholder")}
|
||||
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>
|
||||
</>
|
||||
@@ -404,7 +404,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||||
wizardUrl ||
|
||||
wizardHeaders) && (
|
||||
<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")}
|
||||
</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">
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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 { universalProvidersApi } from "@/lib/api";
|
||||
import {
|
||||
ProviderForm,
|
||||
type ProviderFormValues,
|
||||
} from "@/components/providers/forms/ProviderForm";
|
||||
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
|
||||
import { UniversalProviderPanel } from "@/components/universal";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
open: boolean;
|
||||
@@ -33,46 +27,6 @@ export function AddProviderDialog({
|
||||
onSubmit,
|
||||
}: AddProviderDialogProps) {
|
||||
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(
|
||||
async (values: ProviderFormValues) => {
|
||||
@@ -202,86 +156,46 @@ export function AddProviderDialog({
|
||||
[appId, onSubmit, onOpenChange],
|
||||
);
|
||||
|
||||
// 动态 footer:根据当前 Tab 显示不同按钮
|
||||
const footer =
|
||||
activeTab === "app-specific" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-form"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setUniversalFormOpen(true)}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("universalProvider.add")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
const submitLabel =
|
||||
appId === "claude"
|
||||
? t("provider.addClaudeProvider")
|
||||
: appId === "codex"
|
||||
? t("provider.addCodexProvider")
|
||||
: t("provider.addGeminiProvider");
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="provider-form"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={open}
|
||||
title={t("provider.addNewProvider")}
|
||||
title={submitLabel}
|
||||
onClose={() => onOpenChange(false)}
|
||||
footer={footer}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="app-specific">
|
||||
{t(`apps.${appId}`)} {t("provider.tabProvider")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="universal">
|
||||
{t("provider.tabUniversal")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="app-specific" className="mt-0">
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
showButtons={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="universal" className="mt-0">
|
||||
<UniversalProviderPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Universal Provider Form Modal */}
|
||||
<UniversalProviderFormModal
|
||||
isOpen={universalFormOpen}
|
||||
onClose={handleUniversalFormClose}
|
||||
onSave={handleUniversalProviderSave}
|
||||
initialPreset={selectedUniversalPreset}
|
||||
<ProviderForm
|
||||
appId={appId}
|
||||
submitLabel={t("common.add")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
showButtons={false}
|
||||
/>
|
||||
</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 ${
|
||||
disabled
|
||||
? "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 (
|
||||
<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>
|
||||
<div className="relative">
|
||||
@@ -55,7 +58,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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")}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
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")}
|
||||
</label>
|
||||
@@ -67,7 +67,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.authJsonHint")}
|
||||
</p>
|
||||
)}
|
||||
@@ -120,12 +120,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
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")}
|
||||
</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
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
@@ -167,7 +167,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
)}
|
||||
|
||||
{!configError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("codexConfig.configTomlHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function CodexFormFields({
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
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: "模型名称" })}
|
||||
</label>
|
||||
@@ -110,9 +110,9 @@ export function CodexFormFields({
|
||||
placeholder={t("codexConfig.modelNamePlaceholder", {
|
||||
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", {
|
||||
defaultValue: "指定使用的模型,将自动更新到 config.toml 中",
|
||||
})}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
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)" })}
|
||||
</label>
|
||||
@@ -69,7 +69,7 @@ GEMINI_MODEL=gemini-3-pro-preview`}
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("geminiConfig.envFileHint", {
|
||||
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
|
||||
})}
|
||||
@@ -124,14 +124,14 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
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", {
|
||||
defaultValue: "配置文件 (config.json)",
|
||||
})}
|
||||
</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
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
@@ -180,7 +180,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
)}
|
||||
|
||||
{!configError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("geminiConfig.configJsonHint", {
|
||||
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
||||
})}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
geminiProviderPresets,
|
||||
type GeminiProviderPreset,
|
||||
} from "@/config/geminiProviderPresets";
|
||||
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
|
||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
@@ -73,8 +72,6 @@ interface ProviderFormProps {
|
||||
submitLabel: string;
|
||||
onSubmit: (values: ProviderFormValues) => void;
|
||||
onCancel: () => void;
|
||||
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
|
||||
onManageUniversalProviders?: () => void;
|
||||
initialData?: {
|
||||
name?: string;
|
||||
websiteUrl?: string;
|
||||
@@ -94,8 +91,6 @@ export function ProviderForm({
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onUniversalPresetSelect,
|
||||
onManageUniversalProviders,
|
||||
initialData,
|
||||
showButtons = true,
|
||||
}: ProviderFormProps) {
|
||||
@@ -808,8 +803,6 @@ export function ProviderForm({
|
||||
categoryKeys={categoryKeys}
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={handlePresetChange}
|
||||
onUniversalPresetSelect={onUniversalPresetSelect}
|
||||
onManageUniversalProviders={onManageUniversalProviders}
|
||||
category={category}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
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 { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import {
|
||||
universalProviderPresets,
|
||||
type UniversalProviderPreset,
|
||||
} from "@/config/universalProviderPresets";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -23,8 +18,6 @@ interface ProviderPresetSelectorProps {
|
||||
categoryKeys: string[];
|
||||
presetCategoryLabels: Record<string, string>;
|
||||
onPresetChange: (value: string) => void;
|
||||
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
|
||||
onManageUniversalProviders?: () => void;
|
||||
category?: ProviderCategory; // 当前选中的分类
|
||||
}
|
||||
|
||||
@@ -34,8 +27,6 @@ export function ProviderPresetSelector({
|
||||
categoryKeys,
|
||||
presetCategoryLabels,
|
||||
onPresetChange,
|
||||
onUniversalPresetSelect,
|
||||
onManageUniversalProviders,
|
||||
category,
|
||||
}: ProviderPresetSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -173,49 +164,6 @@ export function ProviderPresetSelector({
|
||||
});
|
||||
})}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function EndpointField({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
{manageButtonLabel || defaultManageLabel}
|
||||
|
||||
@@ -142,13 +142,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="10"
|
||||
value={formData.maxRetries}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
maxRetries: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
maxRetries: parseInt(e.target.value) || 3,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -169,13 +168,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.circuitFailureThreshold}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
circuitFailureThreshold: parseInt(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -208,13 +206,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="180"
|
||||
value={formData.streamingFirstByteTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -235,13 +232,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="600"
|
||||
value={formData.streamingIdleTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
streamingIdleTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
streamingIdleTimeout: parseInt(e.target.value) || 60,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -262,13 +258,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="0"
|
||||
max="1800"
|
||||
value={formData.nonStreamingTimeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
nonStreamingTimeout: isNaN(val) ? 0 : val,
|
||||
});
|
||||
}}
|
||||
nonStreamingTimeout: parseInt(e.target.value) || 300,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -298,13 +293,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.circuitSuccessThreshold}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
|
||||
});
|
||||
}}
|
||||
circuitSuccessThreshold: parseInt(e.target.value) || 2,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -325,13 +319,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="10"
|
||||
max="300"
|
||||
value={formData.circuitTimeoutSeconds}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
|
||||
});
|
||||
}}
|
||||
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -353,13 +346,13 @@ export function AutoFailoverConfigPanel({
|
||||
max="100"
|
||||
step="5"
|
||||
value={Math.round(formData.circuitErrorRateThreshold * 100)}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
|
||||
});
|
||||
}}
|
||||
circuitErrorRateThreshold:
|
||||
(parseInt(e.target.value) || 50) / 100,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -380,13 +373,12 @@ export function AutoFailoverConfigPanel({
|
||||
min="5"
|
||||
max="100"
|
||||
value={formData.circuitMinRequests}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
|
||||
});
|
||||
}}
|
||||
circuitMinRequests: parseInt(e.target.value) || 10,
|
||||
})
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -231,10 +231,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<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")}
|
||||
</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")}
|
||||
</p>
|
||||
<Button
|
||||
@@ -306,10 +306,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||||
{/* 技能列表或无结果提示 */}
|
||||
{filteredSkills.length === 0 ? (
|
||||
<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")}
|
||||
</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")}
|
||||
</p>
|
||||
</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";
|
||||
@@ -62,28 +62,6 @@ export function RequestLogTable() {
|
||||
});
|
||||
};
|
||||
|
||||
// 将 Unix 时间戳转换为本地时间的 datetime-local 格式
|
||||
const timestampToLocalDatetime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 将 datetime-local 格式转换为 Unix 时间戳
|
||||
const localDatetimeToTimestamp = (datetime: string): number | undefined => {
|
||||
if (!datetime) return undefined;
|
||||
// 验证格式是否完整 (YYYY-MM-DDTHH:mm)
|
||||
if (datetime.length < 16) return undefined;
|
||||
const timestamp = new Date(datetime).getTime();
|
||||
// 验证是否为有效日期
|
||||
if (isNaN(timestamp)) return undefined;
|
||||
return Math.floor(timestamp / 1000);
|
||||
};
|
||||
|
||||
const dateLocale =
|
||||
i18n.language === "zh"
|
||||
? "zh-CN"
|
||||
@@ -175,16 +153,19 @@ export function RequestLogTable() {
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
tempFilters.startDate
|
||||
? timestampToLocalDatetime(tempFilters.startDate)
|
||||
? new Date(tempFilters.startDate * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
onChange={(e) =>
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
startDate: timestamp,
|
||||
});
|
||||
}}
|
||||
startDate: e.target.value
|
||||
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>-</span>
|
||||
<Input
|
||||
@@ -192,16 +173,19 @@ export function RequestLogTable() {
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
tempFilters.endDate
|
||||
? timestampToLocalDatetime(tempFilters.endDate)
|
||||
? new Date(tempFilters.endDate * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
onChange={(e) =>
|
||||
setTempFilters({
|
||||
...tempFilters,
|
||||
endDate: timestamp,
|
||||
});
|
||||
}}
|
||||
endDate: e.target.value
|
||||
? Math.floor(new Date(e.target.value).getTime() / 1000)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ interface UsageSummaryCardsProps {
|
||||
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: summary, isLoading } = useUsageSummary(days);
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - days * 24 * 60 * 60;
|
||||
return { startDate: start, endDate: end };
|
||||
}, [days]);
|
||||
|
||||
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalRequests = summary?.totalRequests ?? 0;
|
||||
|
||||
@@ -41,12 +41,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
return {
|
||||
rawDate: stat.date,
|
||||
label: isToday
|
||||
? pointDate.toLocaleString(dateLocale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
|
||||
: pointDate.toLocaleDateString(dateLocale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -54,13 +49,28 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
hour: pointDate.getHours(),
|
||||
inputTokens: stat.totalInputTokens,
|
||||
outputTokens: stat.totalOutputTokens,
|
||||
cacheCreationTokens: stat.totalCacheCreationTokens,
|
||||
cacheReadTokens: stat.totalCacheReadTokens,
|
||||
cost: parseFloat(stat.totalCost),
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const displayData = chartData;
|
||||
const hourlyData = (() => {
|
||||
if (!isToday) return chartData;
|
||||
const map = new Map<number, (typeof chartData)[number]>();
|
||||
chartData.forEach((point) => {
|
||||
map.set(point.hour ?? 0, point);
|
||||
});
|
||||
return Array.from({ length: 24 }, (_, hour) => {
|
||||
const bucket = map.get(hour);
|
||||
return {
|
||||
label: `${hour.toString().padStart(2, "0")}:00`,
|
||||
inputTokens: bucket?.inputTokens ?? 0,
|
||||
outputTokens: bucket?.outputTokens ?? 0,
|
||||
cost: bucket?.cost ?? 0,
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
const displayData = isToday ? hourlyData : chartData;
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
@@ -121,20 +131,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="colorCacheCreation"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor="#f97316" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorCacheRead" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
@@ -186,26 +182,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
fill="url(#colorOutput)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="cacheCreationTokens"
|
||||
name={t("usage.cacheCreationTokens", "缓存创建")}
|
||||
stroke="#f97316"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCacheCreation)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="tokens"
|
||||
type="monotone"
|
||||
dataKey="cacheReadTokens"
|
||||
name={t("usage.cacheReadTokens", "缓存命中")}
|
||||
stroke="#a855f7"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCacheRead)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="cost"
|
||||
type="monotone"
|
||||
|
||||
@@ -391,22 +391,4 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "openrouter",
|
||||
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);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
/**
|
||||
* 保存最后一个非 null/undefined 的值
|
||||
* 用于 Dialog 关闭动画期间保持内容显示
|
||||
*
|
||||
* @param value 当前值
|
||||
* @returns 当前值(如果有效)或最后一个有效值
|
||||
*/
|
||||
export function useLastValidValue<T>(value: T | null | undefined): T | null {
|
||||
const ref = useRef<T | null>(null);
|
||||
|
||||
// 同步更新 ref(在渲染期间,不在 useEffect 中)
|
||||
if (value != null) {
|
||||
ref.current = value;
|
||||
}
|
||||
|
||||
// 返回当前值或最后有效值
|
||||
return value ?? ref.current;
|
||||
}
|
||||
@@ -66,8 +66,6 @@
|
||||
"exitEditMode": "Exit Edit Mode"
|
||||
},
|
||||
"provider": {
|
||||
"tabProvider": "Provider",
|
||||
"tabUniversal": "Universal",
|
||||
"noProviders": "No providers added yet",
|
||||
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
|
||||
"currentlyUsing": "Currently Using",
|
||||
@@ -342,10 +340,6 @@
|
||||
"visitWebsite": "Visit {{url}}",
|
||||
"anthropicModel": "Main 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",
|
||||
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
||||
"anthropicDefaultOpusModel": "Default Opus Model",
|
||||
@@ -423,7 +417,7 @@
|
||||
"cost": "Cost",
|
||||
"perMillion": "(per million)",
|
||||
"trends": "Usage Trends",
|
||||
"rangeToday": "Last 24 hours (hourly)",
|
||||
"rangeToday": "Today (hourly)",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"totalTokens": "Total Tokens",
|
||||
@@ -436,8 +430,8 @@
|
||||
"billingModel": "Billing Model",
|
||||
"inputTokens": "Input",
|
||||
"outputTokens": "Output",
|
||||
"cacheReadTokens": "Cache Hit",
|
||||
"cacheCreationTokens": "Cache Creation",
|
||||
"cacheReadTokens": "Cache Read",
|
||||
"cacheCreationTokens": "Cache Write",
|
||||
"timingInfo": "Duration/TTFT",
|
||||
"status": "Status",
|
||||
"noData": "No data",
|
||||
@@ -453,8 +447,8 @@
|
||||
"displayName": "Display Name",
|
||||
"inputCost": "Input Cost",
|
||||
"outputCost": "Output Cost",
|
||||
"cacheReadCost": "Cache Hit",
|
||||
"cacheWriteCost": "Cache Creation",
|
||||
"cacheReadCost": "Cache Read",
|
||||
"cacheWriteCost": "Cache Write",
|
||||
"deleteConfirmTitle": "Confirm Delete",
|
||||
"deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.",
|
||||
"queryFailed": "Query failed",
|
||||
@@ -481,8 +475,8 @@
|
||||
"timeRange": "Time Range",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Creation",
|
||||
"cacheRead": "Hit"
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "Configure Usage Query",
|
||||
@@ -1063,9 +1057,6 @@
|
||||
}
|
||||
},
|
||||
"switchFailed": "Switch failed: {{error}}",
|
||||
"failover": {
|
||||
"proxyRequired": "Proxy service must be started to configure failover"
|
||||
},
|
||||
"failoverQueue": {
|
||||
"title": "Failover Queue",
|
||||
"description": "Manage failover order for each app's providers",
|
||||
@@ -1137,49 +1128,5 @@
|
||||
"streamingFirstByte": "Streaming First Byte Timeout",
|
||||
"streamingIdle": "Streaming Idle 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": "編集モードを終了"
|
||||
},
|
||||
"provider": {
|
||||
"tabProvider": "プロバイダー",
|
||||
"tabUniversal": "統一プロバイダー",
|
||||
"noProviders": "まだプロバイダーがありません",
|
||||
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
|
||||
"currentlyUsing": "現在使用中",
|
||||
@@ -342,10 +340,6 @@
|
||||
"visitWebsite": "{{url}} を開く",
|
||||
"anthropicModel": "メインモデル",
|
||||
"anthropicSmallFastModel": "高速モデル",
|
||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
||||
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter 互換モード",
|
||||
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
|
||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
||||
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
||||
@@ -423,7 +417,7 @@
|
||||
"cost": "コスト",
|
||||
"perMillion": "(100万あたり)",
|
||||
"trends": "利用トレンド",
|
||||
"rangeToday": "直近24時間 (時間別)",
|
||||
"rangeToday": "今日 (時間別)",
|
||||
"rangeLast7Days": "過去7日間",
|
||||
"rangeLast30Days": "過去30日間",
|
||||
"totalTokens": "総トークン数",
|
||||
@@ -436,8 +430,8 @@
|
||||
"billingModel": "課金モデル",
|
||||
"inputTokens": "入力",
|
||||
"outputTokens": "出力",
|
||||
"cacheReadTokens": "キャッシュヒット",
|
||||
"cacheCreationTokens": "キャッシュ作成",
|
||||
"cacheReadTokens": "キャッシュ読取",
|
||||
"cacheCreationTokens": "キャッシュ書込",
|
||||
"timingInfo": "応答時間/TTFT",
|
||||
"status": "ステータス",
|
||||
"noData": "データなし",
|
||||
@@ -453,8 +447,8 @@
|
||||
"displayName": "表示名",
|
||||
"inputCost": "入力コスト",
|
||||
"outputCost": "出力コスト",
|
||||
"cacheReadCost": "キャッシュヒット",
|
||||
"cacheWriteCost": "キャッシュ作成",
|
||||
"cacheReadCost": "キャッシュ読取",
|
||||
"cacheWriteCost": "キャッシュ書込",
|
||||
"deleteConfirmTitle": "削除の確認",
|
||||
"deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。",
|
||||
"queryFailed": "照会に失敗しました",
|
||||
@@ -481,8 +475,8 @@
|
||||
"timeRange": "期間",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "作成",
|
||||
"cacheRead": "ヒット"
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "利用状況を設定",
|
||||
@@ -1063,9 +1057,6 @@
|
||||
}
|
||||
},
|
||||
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||
"failover": {
|
||||
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります"
|
||||
},
|
||||
"failoverQueue": {
|
||||
"title": "フェイルオーバーキュー",
|
||||
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
|
||||
@@ -1137,48 +1128,5 @@
|
||||
"streamingFirstByte": "ストリーミング初回バイトタイムアウト",
|
||||
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
||||
"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": "退出编辑模式"
|
||||
},
|
||||
"provider": {
|
||||
"tabProvider": "供应商",
|
||||
"tabUniversal": "统一供应商",
|
||||
"noProviders": "还没有添加任何供应商",
|
||||
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
|
||||
"currentlyUsing": "当前使用",
|
||||
@@ -342,10 +340,6 @@
|
||||
"visitWebsite": "访问 {{url}}",
|
||||
"anthropicModel": "主模型",
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
||||
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
|
||||
"openrouterCompatMode": "OpenRouter 兼容模式",
|
||||
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
||||
"anthropicDefaultOpusModel": "Opus 默认模型",
|
||||
@@ -423,7 +417,7 @@
|
||||
"cost": "成本",
|
||||
"perMillion": "(每百万)",
|
||||
"trends": "使用趋势",
|
||||
"rangeToday": "过去 24 小时 (按小时)",
|
||||
"rangeToday": "今天 (按小时)",
|
||||
"rangeLast7Days": "过去 7 天",
|
||||
"rangeLast30Days": "过去 30 天",
|
||||
"totalTokens": "总 Token 数",
|
||||
@@ -436,8 +430,8 @@
|
||||
"billingModel": "计费模型",
|
||||
"inputTokens": "输入",
|
||||
"outputTokens": "输出",
|
||||
"cacheReadTokens": "缓存命中",
|
||||
"cacheCreationTokens": "缓存创建",
|
||||
"cacheReadTokens": "缓存读取",
|
||||
"cacheCreationTokens": "缓存写入",
|
||||
"timingInfo": "用时/首字",
|
||||
"status": "状态",
|
||||
"noData": "暂无数据",
|
||||
@@ -453,8 +447,8 @@
|
||||
"displayName": "显示名称",
|
||||
"inputCost": "输入成本",
|
||||
"outputCost": "输出成本",
|
||||
"cacheReadCost": "缓存命中",
|
||||
"cacheWriteCost": "缓存创建",
|
||||
"cacheReadCost": "缓存读取",
|
||||
"cacheWriteCost": "缓存写入",
|
||||
"deleteConfirmTitle": "确认删除",
|
||||
"deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。",
|
||||
"queryFailed": "查询失败",
|
||||
@@ -481,8 +475,8 @@
|
||||
"timeRange": "时间范围",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "创建",
|
||||
"cacheRead": "命中"
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "配置用量查询",
|
||||
@@ -1063,9 +1057,6 @@
|
||||
}
|
||||
},
|
||||
"switchFailed": "切换失败: {{error}}",
|
||||
"failover": {
|
||||
"proxyRequired": "需要先启动代理服务才能配置故障转移"
|
||||
},
|
||||
"failoverQueue": {
|
||||
"title": "故障转移队列",
|
||||
"description": "管理各应用的供应商故障转移顺序",
|
||||
@@ -1137,49 +1128,5 @@
|
||||
"streamingFirstByte": "流式首字节超时",
|
||||
"streamingIdle": "流式静默超时",
|
||||
"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"],
|
||||
defaultColor: "#FF7000",
|
||||
},
|
||||
newapi: {
|
||||
name: "newapi",
|
||||
displayName: "newapi",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
notion: {
|
||||
name: "notion",
|
||||
displayName: "notion",
|
||||
@@ -338,13 +331,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["aihubmix", "hub", "mix", "aggregator"],
|
||||
defaultColor: "#006FFB",
|
||||
},
|
||||
xiaomimimo: {
|
||||
name: "xiaomimimo",
|
||||
displayName: "Xiaomi MiMo",
|
||||
category: "ai-provider",
|
||||
keywords: ["xiaomimimo", "xiaomi", "mimo"],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
};
|
||||
|
||||
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 { providersApi, universalProvidersApi } from "./providers";
|
||||
export { providersApi } from "./providers";
|
||||
export { settingsApi } from "./settings";
|
||||
export { mcpApi } from "./mcp";
|
||||
export { promptsApi } from "./prompts";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
Provider,
|
||||
UniversalProvider,
|
||||
UniversalProvidersMap,
|
||||
} from "@/types";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "./types";
|
||||
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,11 +49,8 @@ export const usageApi = {
|
||||
return invoke("get_usage_summary", { startDate, endDate });
|
||||
},
|
||||
|
||||
getUsageTrends: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
): Promise<DailyStats[]> => {
|
||||
return invoke("get_usage_trends", { startDate, endDate });
|
||||
getUsageTrends: async (days: number): Promise<DailyStats[]> => {
|
||||
return invoke("get_usage_trends", { days });
|
||||
},
|
||||
|
||||
getProviderStats: async (): Promise<ProviderStats[]> => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { LogFilters } from "@/types/usage";
|
||||
// Query keys
|
||||
export const usageKeys = {
|
||||
all: ["usage"] as const,
|
||||
summary: (days: number) => [...usageKeys.all, "summary", days] as const,
|
||||
summary: (startDate?: number, endDate?: number) =>
|
||||
[...usageKeys.all, "summary", startDate, endDate] as const,
|
||||
trends: (days: number) => [...usageKeys.all, "trends", days] as const,
|
||||
providerStats: () => [...usageKeys.all, "provider-stats"] as const,
|
||||
modelStats: () => [...usageKeys.all, "model-stats"] as const,
|
||||
@@ -18,34 +19,18 @@ export const usageKeys = {
|
||||
[...usageKeys.all, "limits", providerId, appType] as const,
|
||||
};
|
||||
|
||||
const getWindow = (days: number) => {
|
||||
const endDate = Math.floor(Date.now() / 1000);
|
||||
const startDate = endDate - days * 24 * 60 * 60;
|
||||
return { startDate, endDate };
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(days: number) {
|
||||
export function useUsageSummary(startDate?: number, endDate?: number) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summary(days),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
return usageApi.getUsageSummary(startDate, endDate);
|
||||
},
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false, // 后台不刷新
|
||||
queryKey: usageKeys.summary(startDate, endDate),
|
||||
queryFn: () => usageApi.getUsageSummary(startDate, endDate),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsageTrends(days: number) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.trends(days),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
return usageApi.getUsageTrends(startDate, endDate);
|
||||
},
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
queryFn: () => usageApi.getUsageTrends(days),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,8 +38,6 @@ export function useProviderStats() {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.providerStats(),
|
||||
queryFn: usageApi.getProviderStats,
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,8 +45,6 @@ export function useModelStats() {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(),
|
||||
queryFn: usageApi.getModelStats,
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,8 +56,6 @@ export function useRequestLogs(
|
||||
return useQuery({
|
||||
queryKey: usageKeys.logs(filters, page, pageSize),
|
||||
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
|
||||
refetchInterval: 30000, // 每30秒自动刷新
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -186,61 +186,3 @@ export interface McpConfigResponse {
|
||||
configPath: string;
|
||||
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: [],
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Suspense, type ComponentType } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import App from "@/App";
|
||||
import { resetProviderState } from "../msw/state";
|
||||
import { emitTauriEvent } from "../msw/tauriMocks";
|
||||
|
||||
@@ -108,6 +109,20 @@ vi.mock("@/components/ConfirmDialog", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/SettingsPage", () => ({
|
||||
SettingsPage: ({ open, onOpenChange, onImportSuccess }: any) =>
|
||||
open ? (
|
||||
<div data-testid="settings-dialog">
|
||||
<button onClick={() => onImportSuccess?.()}>
|
||||
trigger-import-success
|
||||
</button>
|
||||
<button onClick={() => onOpenChange(false)}>close-settings</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => onOpenChange(true)}>open-settings</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/AppSwitcher", () => ({
|
||||
AppSwitcher: ({ activeApp, onSwitch }: any) => (
|
||||
<div data-testid="app-switcher">
|
||||
@@ -135,12 +150,12 @@ vi.mock("@/components/mcp/McpPanel", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const renderApp = (AppComponent: ComponentType) => {
|
||||
const renderApp = () => {
|
||||
const client = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Suspense fallback={<div data-testid="loading">loading</div>}>
|
||||
<AppComponent />
|
||||
<App />
|
||||
</Suspense>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -154,8 +169,7 @@ describe("App integration with MSW", () => {
|
||||
});
|
||||
|
||||
it("covers basic provider flows via real hooks", async () => {
|
||||
const { default: App } = await import("@/App");
|
||||
renderApp(App);
|
||||
renderApp();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||
@@ -163,6 +177,11 @@ describe("App integration with MSW", () => {
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("update-badge"));
|
||||
expect(screen.getByTestId("settings-dialog")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("trigger-import-success"));
|
||||
fireEvent.click(screen.getByText("close-settings"));
|
||||
|
||||
fireEvent.click(screen.getByText("switch-codex"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||
|
||||
@@ -9,12 +9,7 @@
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2020",
|
||||
"strict": true,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
"include": ["vite.config.mts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { codeInspectorPlugin } from "code-inspector-plugin";
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
export default defineConfig({
|
||||
root: "src",
|
||||
plugins: [
|
||||
command === "serve" &&
|
||||
codeInspectorPlugin({
|
||||
bundler: "vite",
|
||||
}),
|
||||
react(),
|
||||
].filter(Boolean),
|
||||
plugins: [react()],
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
@@ -28,4 +21,4 @@ export default defineConfig(({ command }) => ({
|
||||
},
|
||||
clearScreen: false,
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
}));
|
||||
});
|
||||