Compare commits

..

17 Commits

Author SHA1 Message Date
Jason 2af8c82977 style: cargo fmt 2025-12-29 17:23:17 +08:00
Jason d4286ab90a fix: migrate proxy_config singleton to per-app on startup for v2 databases
Add startup migration for legacy proxy_config tables that still have
singleton structure (no app_type column) even with user_version=2.

This fixes the issue where v3.9.0-2 databases with v2 schema but legacy
proxy_config structure would fail with "no such column: app_type" error.

- Call migrate_proxy_config_to_per_app in create_tables_on_conn
- Add regression test to verify the fix
2025-12-29 17:21:06 +08:00
YoVinchen f7c6947220 fix: add proxy_config base column patches for v3.9.0-2 upgrade
Add base config column patches in create_tables_on_conn():
- proxy_enabled
- listen_address
- listen_port
- enable_logging

Ensures v3.9.0-2 users (user_version=2 but missing columns)
can properly upgrade with all required fields added.
2025-12-29 11:13:44 +08:00
YoVinchen 8fccc2a286 fix(schema): add missing base columns migration for proxy_config
Add compatibility migration for older databases that may be missing
the basic proxy_config columns (proxy_enabled, listen_address,
listen_port, enable_logging) before adding newer timeout fields.
2025-12-29 10:02:35 +08:00
TinsFox 7fdaeacb5b feat: open settings via command comma (#436) 2025-12-28 22:52:51 +08:00
TinsFox 9716eb797f chore: 更新 vite 版本 && 使用 code-inspector-plugin 方便从前端定位到代码位置 (#430)
* chore: 更新 vite 版本 && 使用 code-inspector-plugin 方便从前端定位到代码位置

* fix: update tailwind config path and conditionally load code-inspector-plugin

- Update components.json to reference tailwind.config.cjs instead of deleted tailwind.config.js
- Load codeInspectorPlugin only in dev mode to avoid unnecessary code in production builds

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-28 21:34:41 +08:00
lif 91deaf094e fix: 移除已废弃的 sync_enabled_to_codex 调用 (#460)
* fix(mcp): 移除同步Codex Provider时的旧MCP同步调用

sync_enabled_to_codex使用旧的config.mcp.codex结构,
在v3.7.0统一结构中该字段为空,导致MCP配置被错误清除。
MCP同步应通过McpService进行。

Fixes #403

* test(mcp): update test to reflect new MCP sync architecture

Remove MCP-related assertions from sync_codex_provider_writes_auth_and_config
test since provider switching no longer triggers MCP sync in v3.7.0+.

MCP synchronization is now handled independently by McpService,
not as part of the provider switch flow.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-27 21:12:07 +08:00
lif 3a548152a9 fix: MCP同步时优雅处理无效的Codex config.toml (#461)
* fix(mcp): 移除同步Codex Provider时的旧MCP同步调用

sync_enabled_to_codex使用旧的config.mcp.codex结构,
在v3.7.0统一结构中该字段为空,导致MCP配置被错误清除。
MCP同步应通过McpService进行。

Fixes #403

* fix(mcp): 优雅处理Codex配置文件解析失败的情况

当~/.codex/config.toml存在但内容无效时,MCP同步操作会失败,
导致后续provider切换等操作也失败。

修改sync_single_server_to_codex和remove_server_from_codex函数,
在配置文件解析失败时进行容错处理而不是返回错误。

Fixes #393
2025-12-27 18:05:58 +08:00
lif a8f7cda167 fix(macos): use .app bundle path for autostart to prevent terminal window (#462)
On macOS, the auto-launch library requires the .app bundle path (e.g.,
/Applications/CC Switch.app) rather than the binary path inside the bundle
(e.g., .app/Contents/MacOS/CC Switch). Using the binary path directly
causes AppleScript login items to open a terminal window.

This fix extracts the .app bundle path from current_exe() on macOS,
ensuring proper integration with macOS login items.

Closes #375
2025-12-27 16:45:17 +08:00
Calcium-Ion 8fe5c1041a feat: add Universal Provider feature (#348)
* feat: add Universal Provider feature

- Add Universal Provider data structures and type definitions
- Implement backend CRUD operations and sync functionality
- Add frontend UI components (UniversalProviderPanel, Card, FormModal)
- Add NewAPI icon and preset configuration
- Support cross-app (Claude/Codex/Gemini) configuration sync
- Add website URL field for providers
- Implement real-time refresh via event notifications
- Add i18n support (Chinese/English/Japanese)

* feat: integrate universal provider presets into add provider dialog

- Add universal provider presets (NewAPI, Custom Gateway) to preset selector
- Show universal presets with Layers icon badge in preset selector
- Open UniversalProviderFormModal when universal preset is clicked
- Pass initialPreset to auto-fill form when opened from add dialog
- Add i18n keys for addSuccess/addFailed messages
- Keep separate universal provider panel for management

* refactor: move universal provider management to add dialog

- Remove Layers button from main navigation header
- Add 'Manage' button next to universal provider presets
- Open UniversalProviderPanel from within add provider dialog
- Add i18n keys for 'manage' in all locales

* style: display universal provider presets on separate line

- Move universal provider section to a new row with border separator
- Add label '统一供应商:' to clarify the section

* style: unify universal provider label style with preset label

- Use FormLabel component for consistent styling
- Add background to 'Manage' button matching preset buttons
- Update icon size and button padding for consistency

* feat: add sync functionality and JSON preview for Universal Provider

* fix: add missing in_failover_queue field to Provider structs

After rebasing to main, the Provider struct gained a new
`in_failover_queue` field. This fix adds the missing field
to the three to_*_provider() methods in UniversalProvider.

* refactor: redesign AddProviderDialog with tab-based layout

- Add tabs to separate app-specific providers and universal providers
- Move "Add Universal Provider" button from panel header to footer
- Remove unused handleAdd callback and clean up imports
- Update emptyHint i18n text to reference the footer button

* fix: append /v1 suffix to Codex base_url in Universal Provider

Codex uses OpenAI-compatible API which requires the /v1 endpoint suffix.
The Universal Provider now automatically appends /v1 to base_url when
generating Codex provider config if not already present.

- Handle trailing slashes to avoid double slashes
- Apply fix to both backend (to_codex_provider) and frontend preview

* feat: auto-sync universal provider to apps on creation

Previously, users had to manually click sync after adding a universal
provider. Now it automatically syncs to Claude/Codex/Gemini on creation,
providing a smoother user experience.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-12-26 22:47:24 +08:00
Jason a24753f074 fix(i18n): add missing translations for reasoning model and OpenRouter compat mode
Add missing i18n keys introduced in commit e6f18ba:
- providerForm.anthropicReasoningModel
- providerForm.reasoningModelPlaceholder
- providerForm.openrouterCompatMode
- providerForm.openrouterCompatModeHint
- proxy.failover.proxyRequired
2025-12-26 00:01:59 +08:00
Jason 079ee687a8 docs: make sponsor logos clickable and update sponsor list
- Make all sponsor logos clickable links in README files (EN/ZH/JA)
- Replace ShanDianShuo with DMXAPI sponsor
- Add DMXAPI logo images (dmx-en.jpg, dmx-zh.jpeg)
- Unify sponsor list across all language versions
2025-12-25 23:30:45 +08:00
Jason e08c67b88f chore: update GLM partner banner images 2025-12-25 22:53:32 +08:00
Jason bb2756d0fb fix(ui): improve dark mode text contrast for form labels
Replace hardcoded Tailwind color classes with design system CSS variables
to improve text visibility in dark mode:

- text-gray-900 dark:text-gray-100 → text-foreground
- text-gray-500/600 dark:text-gray-400 → text-muted-foreground
- bg-white dark:bg-gray-800 → bg-background
2025-12-25 17:37:11 +08:00
Weiyi Xu c87bb43aaa feat: add xiaomi mimo icon and claude provider configuration (#470) 2025-12-25 17:30:29 +08:00
Jason 8f58c08d0d fix(database): add backward compatibility check for proxy_config seed insert
Add has_column check before inserting seed data into proxy_config table.
This prevents SQL errors when upgrading from older databases where
proxy_config was a singleton table without the app_type column.

The migration function will handle the table structure upgrade and
insert the three rows after converting to the new schema.
2025-12-25 16:04:34 +08:00
YoVinchen e6f18ba801 Feat/usage model extraction (#455)
* feat(proxy): extract model name from API response for accurate usage tracking

- Add model field extraction in TokenUsage parsing for Claude, OpenAI, and Codex
- Prioritize response model over request model in usage logging
- Update model extractors to use parsed usage.model first
- Add tests for model extraction in stream and non-stream responses

* feat(proxy): implement streaming timeout control with validation

- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings

* feat(proxy): add model mapping module for provider-based model substitution

- Add model_mapper.rs with ModelMapping struct to extract model configs from Provider
- Support ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, and default models for haiku/sonnet/opus
- Implement thinking mode detection for reasoning model priority
- Include comprehensive unit tests for all mapping scenarios

* fix(proxy): bypass circuit breaker for single provider scenario

When failover is disabled (single provider), circuit breaker open state
would block all requests causing poor UX. Now bypasses circuit breaker
check in this scenario. Also integrates model mapping into request flow.

* feat(ui): add reasoning model field to Claude provider form

Add ANTHROPIC_REASONING_MODEL configuration field for Claude providers,
allowing users to specify a dedicated model for thinking/reasoning tasks.

* feat(proxy): add openrouter_compat_mode for optional format conversion

Add configurable OpenRouter compatibility mode that enables Anthropic to
OpenAI format conversion. When enabled, rewrites endpoint to /v1/chat/completions
and transforms request/response formats. Defaults to enabled for OpenRouter.

* feat(ui): add OpenRouter compatibility mode toggle

Add UI toggle for OpenRouter providers to enable/disable compatibility
mode which uses OpenAI Chat Completions format with SSE conversion.

* feat(stream-check): use provider-configured model for health checks

Extract model from provider's settings_config (ANTHROPIC_MODEL, GEMINI_MODEL,
or Codex config.toml) instead of always using default test models.

* refactor(ui): remove timeout settings from AutoFailoverConfigPanel

Remove streaming/non-streaming timeout configuration from failover panel
as these settings have been moved to a dedicated location.

* refactor(database): migrate proxy_config to per-app three-row structure

Replace singleton proxy_config table with app_type primary key structure,
allowing independent proxy settings for Claude, Codex, and Gemini.
Add GlobalProxyConfig queries and per-app config management in DAO layer.

* feat(proxy): add GlobalProxyConfig and AppProxyConfig types

Add new type definitions for the refactored proxy configuration:
- GlobalProxyConfig: shared settings (enabled, address, port, logging)
- AppProxyConfig: per-app settings (failover, timeouts, circuit breaker)

* refactor(proxy): update service layer for per-app config structure

Adapt proxy service, handler context, and provider router to use
the new per-app configuration model. Read enabled/timeout settings
from proxy_config table instead of settings table.

* feat(commands): add global and per-app proxy config commands

Add new Tauri commands for the refactored proxy configuration:
- get_global_proxy_config / update_global_proxy_config
- get_proxy_config_for_app / update_proxy_config_for_app
Update startup restore logic to read from proxy_config table.

* feat(api): add frontend API and Query hooks for proxy config

Add TypeScript wrappers and TanStack Query hooks for:
- Global proxy config (address, port, logging)
- Per-app proxy config (failover, timeouts, circuit breaker)
- Proxy takeover status management

* refactor(ui): redesign proxy panel with inline config controls

Replace ProxySettingsDialog with inline controls in ProxyPanel.
Add per-app takeover switches and global address/port settings.
Simplify AutoFailoverConfigPanel by removing timeout settings.

* feat(i18n): add proxy takeover translations and update types

Add i18n strings for proxy takeover status in zh/en/ja.
Update TypeScript types for GlobalProxyConfig and AppProxyConfig.

* refactor(proxy): load circuit breaker config per-app instead of globally

Extract app_type from router key and read circuit breaker settings
from the corresponding proxy_config row for each application.
2025-12-25 10:40:11 +08:00
56 changed files with 3400 additions and 372 deletions
+1 -1
View File
@@ -1 +1 @@
v22.4.1
22.12.0
+8 -9
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,7 +15,7 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
## ❤️Sponsor
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
@@ -23,19 +23,18 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
<tr>
<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>
<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>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses.AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, youll receive an extra 10% bonus credit on your first top-up!
</td>
<td width="180"><a href="https://www.dmxapi.cn/register?aff=bUHu"><img src="assets/partners/logos/dmx-en.jpg" alt="DMXAPI" width="150"></a></td>
<td>Thanks to 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>
</tr>
</table>
@@ -48,7 +47,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
## Features
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
### Current Version: v3.8.3 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
**v3.8.0 Major Update (2025-11-28)**
+8 -10
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,7 +15,7 @@
## ❤️スポンサー
![Zhipu GLM](assets/partners/banners/glm-en.jpg)
[![Zhipu GLM](assets/partners/banners/glm-en.jpg)](https://z.ai/subscribe?ic=8JVLJQFSKB)
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
@@ -23,20 +23,18 @@
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
</tr>
<tr>
<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>
<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>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!
</td>
<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>
</tr>
</table>
@@ -49,7 +47,7 @@
## 特長
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
### 現在のバージョン:v3.8.3 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
+10 -10
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.3-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
@@ -15,7 +15,7 @@
## ❤️赞助商
![智谱 GLM](assets/partners/banners/glm-zh.jpg)
[![智谱 GLM](assets/partners/banners/glm-zh.jpg)](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
@@ -23,20 +23,20 @@
<table>
<tr>
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠。</td>
</tr>
<tr>
<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 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编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。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.2 | [完整更新日志](CHANGELOG.md)
### 当前版本:v3.8.3 | [完整更新日志](CHANGELOG.md)
**v3.8.0 重大更新(2025-11-28**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"config": "tailwind.config.cjs",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
+4 -2
View File
@@ -2,6 +2,7 @@
"name": "cc-switch",
"version": "3.9.0-2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
"dev": "pnpm tauri dev",
"build": "pnpm tauri build",
@@ -27,6 +28,7 @@
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20",
"code-inspector-plugin": "^1.3.3",
"cross-fetch": "^4.1.0",
"jsdom": "^25.0.0",
"msw": "^2.11.6",
@@ -34,7 +36,7 @@
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite": "^7.3.0",
"vitest": "^2.0.5"
},
"dependencies": {
@@ -85,4 +87,4 @@
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
}
+517 -25
View File
File diff suppressed because it is too large Load Diff
+71 -4
View File
@@ -1,16 +1,36 @@
use crate::error::AppError;
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
/// 获取 macOS 上的 .app bundle 路径
/// 将 `/path/to/CC Switch.app/Contents/MacOS/CC Switch` 转换为 `/path/to/CC Switch.app`
#[cfg(target_os = "macos")]
fn get_macos_app_bundle_path(exe_path: &std::path::Path) -> Option<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 app_path =
let exe_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
// macOS 需要使用 .app bundle 路径,否则 AppleScript login item 会打开终端
#[cfg(target_os = "macos")]
let app_path = get_macos_app_bundle_path(&exe_path).unwrap_or(exe_path);
#[cfg(not(target_os = "macos"))]
let app_path = exe_path;
// 使用 AutoLaunchBuilder 消除平台差异
// Windows/Linux: new() 接受 3 参数
// macOS: new() 接受 4 参数(含 hidden 参数)
// Builder 模式自动处理这些差异
// macOS: 使用 AppleScript 方式(默认),需要 .app bundle 路径
// Windows/Linux: 使用注册表/XDG autostart
let auto_launch = AutoLaunchBuilder::new()
.set_app_name(app_name)
.set_app_path(&app_path.to_string_lossy())
@@ -47,3 +67,50 @@ 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);
}
}
+94
View File
@@ -229,3 +229,97 @@ pub fn update_providers_sort_order(
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string())
}
// ============================================================================
// 统一供应商(Universal Provider)命令
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
/// 统一供应商同步完成事件的 payload
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
/// 操作类型: "upsert" | "delete" | "sync"
pub action: String,
/// 统一供应商 ID
pub id: String,
}
/// 发送统一供应商同步事件,通知前端刷新供应商列表
fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) {
let _ = app.emit(
"universal-provider-synced",
UniversalProviderSyncedEvent {
action: action.to_string(),
id: id.to_string(),
},
);
}
/// 获取所有统一供应商
#[tauri::command]
pub fn get_universal_providers(
state: State<'_, AppState>,
) -> Result<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)
}
+1
View File
@@ -10,6 +10,7 @@ pub mod proxy;
pub mod settings;
pub mod skills;
pub mod stream_check;
pub mod universal_providers;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
// 导出 FailoverQueueItem 供外部使用
@@ -0,0 +1,74 @@
//! 统一供应商 (Universal Provider) DAO
//!
//! 提供统一供应商的 CRUD 操作。
use crate::database::{lock_conn, to_json_string, Database};
use crate::error::AppError;
use crate::provider::UniversalProvider;
use std::collections::HashMap;
/// 统一供应商的 Settings Key
const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
impl Database {
/// 获取所有统一供应商
pub fn get_all_universal_providers(
&self,
) -> Result<HashMap<String, UniversalProvider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?")
.map_err(|e| AppError::Database(e.to_string()))?;
let result: Option<String> = stmt
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
.ok();
match result {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
None => Ok(HashMap::new()),
}
}
/// 获取单个统一供应商
pub fn get_universal_provider(&self, id: &str) -> Result<Option<UniversalProvider>, AppError> {
let providers = self.get_all_universal_providers()?;
Ok(providers.get(id).cloned())
}
/// 保存统一供应商(添加或更新)
pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> {
let mut providers = self.get_all_universal_providers()?;
providers.insert(provider.id.clone(), provider.clone());
self.save_all_universal_providers(&providers)
}
/// 删除统一供应商
pub fn delete_universal_provider(&self, id: &str) -> Result<bool, AppError> {
let mut providers = self.get_all_universal_providers()?;
let existed = providers.remove(id).is_some();
if existed {
self.save_all_universal_providers(&providers)?;
}
Ok(existed)
}
/// 保存所有统一供应商(内部方法)
fn save_all_universal_providers(
&self,
providers: &HashMap<String, UniversalProvider>,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let json = to_json_string(providers)?;
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
[UNIVERSAL_PROVIDERS_KEY, &json],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
+85 -27
View File
@@ -113,33 +113,39 @@ impl Database {
)", []).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()))?;
//
// 兼容旧数据库:
// - 老版本 proxy_config 是单例表(没有 app_type 列),此时不能执行三行 seed insert
// - 旧表会在 apply_schema_migrations() 中迁移为三行结构后再插入。
if Self::has_column(conn, "proxy_config", "app_type")? {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// 9. Provider Health 表
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
@@ -227,6 +233,24 @@ 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",
@@ -241,6 +265,14 @@ 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,
@@ -405,6 +437,32 @@ 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",
+47
View File
@@ -201,6 +201,53 @@ fn migration_aligns_column_defaults_and_types() {
);
}
#[test]
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let conn = Connection::open_in_memory().expect("open memory db");
// 模拟测试版 v2user_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
+6
View File
@@ -694,6 +694,12 @@ 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
+16 -6
View File
@@ -359,9 +359,14 @@ pub fn sync_single_server_to_codex(
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
// 尝试解析现有配置,如果失败则创建新文档(容错处理)
match content.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
toml_edit::DocumentMut::new()
}
}
} else {
toml_edit::DocumentMut::new()
};
@@ -409,9 +414,14 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
let mut doc = match content.parse::<toml_edit::DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
return Ok(());
}
};
// 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
+283
View File
@@ -173,3 +173,286 @@ impl ProviderManager {
&self.providers
}
}
// ============================================================================
// 统一供应商(Universal Provider- 跨应用共享配置
// ============================================================================
/// 统一供应商的应用启用状态
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
/// Claude 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClaudeModelConfig {
/// 主模型
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Haiku 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "haikuModel")]
pub haiku_model: Option<String>,
/// Sonnet 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sonnetModel")]
pub sonnet_model: Option<String>,
/// Opus 默认模型
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "opusModel")]
pub opus_model: Option<String>,
}
/// Codex 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CodexModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 推理强度
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "reasoningEffort")]
pub reasoning_effort: Option<String>,
}
/// Gemini 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GeminiModelConfig {
/// 模型名称
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// 各应用的模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UniversalProviderModels {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<ClaudeModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex: Option<CodexModelConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gemini: Option<GeminiModelConfig>,
}
/// 统一供应商(跨应用共享配置)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UniversalProvider {
/// 唯一标识
pub id: String,
/// 供应商名称
pub name: String,
/// 供应商类型(如 "newapi", "custom"
#[serde(rename = "providerType")]
pub provider_type: String,
/// 应用启用状态
pub apps: UniversalProviderApps,
/// API 基础地址
#[serde(rename = "baseUrl")]
pub base_url: String,
/// API 密钥
#[serde(rename = "apiKey")]
pub api_key: String,
/// 各应用的模型配置
#[serde(default)]
pub models: UniversalProviderModels,
/// 网站链接
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "websiteUrl")]
pub website_url: Option<String>,
/// 备注信息
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// 图标名称
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 元数据
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 创建时间戳
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "createdAt")]
pub created_at: Option<i64>,
/// 排序索引
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "sortIndex")]
pub sort_index: Option<usize>,
}
impl UniversalProvider {
/// 创建新的统一供应商
pub fn new(
id: String,
name: String,
provider_type: String,
base_url: String,
api_key: String,
) -> Self {
Self {
id,
name,
provider_type,
apps: UniversalProviderApps::default(),
base_url,
api_key,
models: UniversalProviderModels::default(),
website_url: None,
notes: None,
icon: None,
icon_color: None,
meta: None,
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
}
}
/// 生成 Claude 供应商配置
pub fn to_claude_provider(&self) -> Option<Provider> {
if !self.apps.claude {
return None;
}
let models = self.models.claude.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
let haiku = models
.and_then(|m| m.haiku_model.clone())
.unwrap_or_else(|| model.clone());
let sonnet = models
.and_then(|m| m.sonnet_model.clone())
.unwrap_or_else(|| model.clone());
let opus = models
.and_then(|m| m.opus_model.clone())
.unwrap_or_else(|| model.clone());
let settings_config = serde_json::json!({
"env": {
"ANTHROPIC_BASE_URL": self.base_url,
"ANTHROPIC_AUTH_TOKEN": self.api_key,
"ANTHROPIC_MODEL": model,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku,
"ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet,
"ANTHROPIC_DEFAULT_OPUS_MODEL": opus,
}
});
Some(Provider {
id: format!("universal-claude-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Codex 供应商配置
pub fn to_codex_provider(&self) -> Option<Provider> {
if !self.apps.codex {
return None;
}
let models = self.models.codex.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gpt-4o".to_string());
let reasoning_effort = models
.and_then(|m| m.reasoning_effort.clone())
.unwrap_or_else(|| "high".to_string());
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
let codex_base_url = if self.base_url.ends_with("/v1") {
self.base_url.clone()
} else {
format!("{}/v1", self.base_url.trim_end_matches('/'))
};
// 生成 Codex 的 config.toml 内容
let config_toml = format!(
r#"model_provider = "newapi"
model = "{}"
model_reasoning_effort = "{}"
disable_response_storage = true
[model_providers.newapi]
name = "NewAPI"
base_url = "{}"
wire_api = "responses"
requires_openai_auth = true"#,
model, reasoning_effort, codex_base_url
);
let settings_config = serde_json::json!({
"auth": {
"OPENAI_API_KEY": self.api_key
},
"config": config_toml
});
Some(Provider {
id: format!("universal-codex-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
/// 生成 Gemini 供应商配置
pub fn to_gemini_provider(&self) -> Option<Provider> {
if !self.apps.gemini {
return None;
}
let models = self.models.gemini.as_ref();
let model = models
.and_then(|m| m.model.clone())
.unwrap_or_else(|| "gemini-2.5-pro".to_string());
let settings_config = serde_json::json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": self.base_url,
"GEMINI_API_KEY": self.api_key,
"GEMINI_MODEL": model,
}
});
Some(Provider {
id: format!("universal-gemini-{}", self.id),
name: self.name.clone(),
settings_config,
website_url: self.website_url.clone(),
category: Some("aggregator".to_string()),
created_at: self.created_at,
sort_index: self.sort_index,
notes: self.notes.clone(),
meta: self.meta.clone(),
icon: self.icon.clone(),
icon_color: self.icon_color.clone(),
in_failover_queue: false,
})
}
}
+3 -1
View File
@@ -146,7 +146,9 @@ impl ConfigService {
let cfg_text = settings.get("config").and_then(Value::as_str);
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
crate::mcp::sync_enabled_to_codex(config)?;
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
+137
View File
@@ -690,3 +690,140 @@ pub struct ProviderSortUpdate {
#[serde(rename = "sortIndex")]
pub sort_index: usize,
}
// ============================================================================
// 统一供应商(Universal Provider)服务方法
// ============================================================================
use crate::provider::UniversalProvider;
use std::collections::HashMap;
impl ProviderService {
/// 获取所有统一供应商
pub fn list_universal(
state: &AppState,
) -> Result<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)
}
/// 递归合并 JSONbase 为底,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();
}
}
}
}
+5 -15
View File
@@ -76,19 +76,8 @@ fn sync_codex_provider_writes_auth_and_config() {
let mut config = MultiAppConfig::default();
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
config.mcp.codex.servers.insert(
"echo-server".into(),
json!({
"id": "echo-server",
"enabled": true,
"server": {
"type": "stdio",
"command": "echo",
"args": ["hello"]
}
}),
);
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
let provider_config = json!({
"auth": {
@@ -133,9 +122,10 @@ fn sync_codex_provider_writes_auth_and_config() {
);
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
// 验证基础配置正确写入
assert!(
toml_text.contains("command = \"echo\""),
"config.toml should contain serialized enabled MCP server"
toml_text.contains("base_url"),
"config.toml should contain base_url from provider config"
);
// 当前供应商应同步最新 config 文本
+66 -1
View File
@@ -43,9 +43,17 @@ import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal";
import { Button } from "@/components/ui/button";
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
type View =
| "providers"
| "settings"
| "prompts"
| "skills"
| "mcp"
| "agents"
| "universal";
const DRAG_BAR_HEIGHT = 28; // px
const HEADER_HEIGHT = 64; // px
@@ -145,6 +153,38 @@ function App() {
};
}, [activeApp, refetch]);
// 监听统一供应商同步事件,刷新所有应用的供应商列表
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setupListener = async () => {
try {
const { listen } = await import("@tauri-apps/api/event");
unsubscribe = await listen("universal-provider-synced", async () => {
// 统一供应商同步后刷新所有应用的供应商列表
// 使用 invalidateQueries 使所有 providers 查询失效
await queryClient.invalidateQueries({ queryKey: ["providers"] });
// 同时更新托盘菜单
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.error("[App] Failed to update tray menu", error);
}
});
} catch (error) {
console.error(
"[App] Failed to subscribe universal-provider-synced event",
error,
);
}
};
setupListener();
return () => {
unsubscribe?.();
};
}, [queryClient]);
// 应用启动时检测所有应用的环境变量冲突
useEffect(() => {
const checkEnvOnStartup = async () => {
@@ -222,6 +262,21 @@ function App() {
checkEnvOnSwitch();
}, [activeApp]);
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
if (event.key !== "," || !(event.metaKey || event.ctrlKey)) {
return;
}
event.preventDefault();
setCurrentView("settings");
};
window.addEventListener("keydown", handleGlobalShortcut);
return () => {
window.removeEventListener("keydown", handleGlobalShortcut);
};
}, []);
// 打开网站链接
const handleOpenWebsite = async (url: string) => {
try {
@@ -364,6 +419,12 @@ 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">
@@ -494,6 +555,10 @@ function App() {
{currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")}
{currentView === "universal" &&
t("universalProvider.title", {
defaultValue: "统一供应商",
})}
</h1>
</div>
) : (
+2 -2
View File
@@ -191,11 +191,11 @@ export function EnvWarningBanner({
<div className="flex-1 min-w-0">
<label
htmlFor={key}
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
className="block text-sm font-medium text-foreground cursor-pointer"
>
{conflict.varName}
</label>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
<p className="text-xs text-muted-foreground mt-1 break-all">
{t("env.field.value")}: {conflict.varValue}
</p>
<p className="text-xs text-muted-foreground mt-1">
+15 -15
View File
@@ -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-gray-500 dark:text-gray-400">
<p className="text-sm text-muted-foreground">
{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-gray-900 dark:text-gray-100">
<label className="mb-2 block text-sm font-medium text-foreground">
{t("mcp.wizard.type")} <span className="text-red-500">*</span>
</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-gray-900 dark:text-gray-100">
<span className="text-sm text-foreground">
{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-gray-900 dark:text-gray-100">
<span className="text-sm text-foreground">
{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-gray-900 dark:text-gray-100">
<span className="text-sm text-foreground">
{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-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
<Input
@@ -317,7 +317,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<>
{/* Command */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.command")}{" "}
<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-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{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-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
{/* Env */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.env")}
</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-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -366,7 +366,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<>
{/* URL */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{t("mcp.wizard.url")}{" "}
<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-gray-900 dark:text-gray-100">
<label className="mb-1 block text-sm font-medium text-foreground">
{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-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
className="w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -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-gray-900 dark:text-gray-100">
<h3 className="text-sm font-medium text-foreground">
{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">
+121 -35
View File
@@ -1,17 +1,23 @@
import { useCallback } from "react";
import { useCallback, useState } 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 } from "@/types";
import type { Provider, CustomEndpoint, UniversalProvider } 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;
@@ -27,6 +33,46 @@ 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) => {
@@ -156,46 +202,86 @@ export function AddProviderDialog({
[appId, onSubmit, onOpenChange],
);
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>
</>
);
// 动态 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>
</>
);
return (
<FullScreenPanel
isOpen={open}
title={submitLabel}
title={t("provider.addNewProvider")}
onClose={() => onOpenChange(false)}
footer={footer}
>
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
<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}
/>
</FullScreenPanel>
);
@@ -31,15 +31,12 @@ 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 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
: "border-border-default bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
}`;
return (
<div className="space-y-2">
<label
htmlFor={id}
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
<label htmlFor={id} className="block text-sm font-medium text-foreground">
{label} {required && "*"}
</label>
<div className="relative">
@@ -58,7 +55,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
<button
type="button"
onClick={toggleShowKey}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={showKey ? t("apiKeyInput.hide") : t("apiKeyInput.show")}
>
{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-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.authJson")}
</label>
@@ -67,7 +67,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
)}
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{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-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("codexConfig.configToml")}
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
@@ -167,7 +167,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
)}
{!configError && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{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-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{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 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{t("codexConfig.modelNameHint", {
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-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
@@ -69,7 +69,7 @@ GEMINI_MODEL=gemini-3-pro-preview`}
)}
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{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-gray-900 dark:text-gray-100"
className="block text-sm font-medium text-foreground"
>
{t("geminiConfig.configJson", {
defaultValue: "配置文件 (config.json)",
})}
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
@@ -180,7 +180,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
)}
{!configError && (
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs text-muted-foreground">
{t("geminiConfig.configJsonHint", {
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
})}
@@ -20,6 +20,7 @@ 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";
@@ -72,6 +73,8 @@ interface ProviderFormProps {
submitLabel: string;
onSubmit: (values: ProviderFormValues) => void;
onCancel: () => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
initialData?: {
name?: string;
websiteUrl?: string;
@@ -91,6 +94,8 @@ export function ProviderForm({
submitLabel,
onSubmit,
onCancel,
onUniversalPresetSelect,
onManageUniversalProviders,
initialData,
showButtons = true,
}: ProviderFormProps) {
@@ -803,6 +808,8 @@ export function ProviderForm({
categoryKeys={categoryKeys}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
onUniversalPresetSelect={onUniversalPresetSelect}
onManageUniversalProviders={onManageUniversalProviders}
category={category}
/>
)}
@@ -1,11 +1,16 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { Zap, Star } from "lucide-react";
import { Zap, Star, Layers, Settings2 } from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { 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;
@@ -18,6 +23,8 @@ interface ProviderPresetSelectorProps {
categoryKeys: string[];
presetCategoryLabels: Record<string, string>;
onPresetChange: (value: string) => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
category?: ProviderCategory; // 当前选中的分类
}
@@ -27,6 +34,8 @@ export function ProviderPresetSelector({
categoryKeys,
presetCategoryLabels,
onPresetChange,
onUniversalPresetSelect,
onManageUniversalProviders,
category,
}: ProviderPresetSelectorProps) {
const { t } = useTranslation();
@@ -164,6 +173,49 @@ 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-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Zap className="h-3.5 w-3.5" />
{manageButtonLabel || defaultManageLabel}
+4 -4
View File
@@ -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-gray-900 dark:text-gray-100">
<p className="text-lg font-medium text-foreground">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</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-gray-900 dark:text-gray-100">
<p className="text-lg font-medium text-foreground">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
</div>
@@ -0,0 +1,115 @@
import { useTranslation } from "react-i18next";
import { Edit2, Trash2, RefreshCw, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ProviderIcon } from "@/components/ProviderIcon";
import type { UniversalProvider } from "@/types";
interface UniversalProviderCardProps {
provider: UniversalProvider;
onEdit: (provider: UniversalProvider) => void;
onDelete: (id: string) => void;
onSync: (id: string) => void;
}
export function UniversalProviderCard({
provider,
onEdit,
onDelete,
onSync,
}: UniversalProviderCardProps) {
const { t } = useTranslation();
// 获取启用的应用列表
const enabledApps: string[] = [
provider.apps.claude ? "Claude" : null,
provider.apps.codex ? "Codex" : null,
provider.apps.gemini ? "Gemini" : null,
].filter((app): app is string => app !== null);
return (
<div className="group relative rounded-xl border border-border/50 bg-card p-4 transition-all hover:border-border hover:shadow-md">
{/* 头部:图标和名称 */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent">
<ProviderIcon icon={provider.icon} name={provider.name} size={24} />
</div>
<div>
<h3 className="font-semibold text-foreground">{provider.name}</h3>
<p className="text-xs text-muted-foreground">
{provider.providerType}
</p>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onSync(provider.id)}
title={t("universalProvider.sync", { defaultValue: "同步到应用" })}
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(provider)}
title={t("common.edit", { defaultValue: "编辑" })}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDelete(provider.id)}
title={t("common.delete", { defaultValue: "删除" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 配置信息 */}
<div className="mt-4 space-y-2">
{/* Base URL */}
<div className="flex items-center gap-2 text-sm">
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate text-muted-foreground">
{provider.baseUrl || "-"}
</span>
</div>
{/* 启用的应用 */}
<div className="flex flex-wrap gap-1.5">
{enabledApps.map((app) => (
<span
key={app}
className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{app}
</span>
))}
{enabledApps.length === 0 && (
<span className="text-xs text-muted-foreground">
{t("universalProvider.noAppsEnabled", {
defaultValue: "未启用任何应用",
})}
</span>
)}
</div>
</div>
{/* 备注 */}
{provider.notes && (
<p className="mt-3 text-xs text-muted-foreground line-clamp-2">
{provider.notes}
</p>
)}
</div>
);
}
@@ -0,0 +1,718 @@
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>
);
}
@@ -0,0 +1,288 @@
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>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { UniversalProviderPanel } from "./UniversalProviderPanel";
export { UniversalProviderCard } from "./UniversalProviderCard";
export { UniversalProviderFormModal } from "./UniversalProviderFormModal";
+18
View File
@@ -391,4 +391,22 @@ 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",
},
];
+131
View File
@@ -0,0 +1,131 @@
/**
* 统一供应商(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);
}
+53
View File
@@ -66,6 +66,8 @@
"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",
@@ -340,6 +342,10 @@
"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",
@@ -1057,6 +1063,9 @@
}
},
"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",
@@ -1128,5 +1137,49 @@
"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)"
}
}
+52
View File
@@ -66,6 +66,8 @@
"exitEditMode": "編集モードを終了"
},
"provider": {
"tabProvider": "プロバイダー",
"tabUniversal": "統一プロバイダー",
"noProviders": "まだプロバイダーがありません",
"noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください",
"currentlyUsing": "現在使用中",
@@ -340,6 +342,10 @@
"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 モデル",
@@ -1057,6 +1063,9 @@
}
},
"switchFailed": "切り替えに失敗しました: {{error}}",
"failover": {
"proxyRequired": "フェイルオーバーを設定するには、プロキシサービスを先に起動する必要があります"
},
"failoverQueue": {
"title": "フェイルオーバーキュー",
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
@@ -1128,5 +1137,48 @@
"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": "以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)"
}
}
+53
View File
@@ -66,6 +66,8 @@
"exitEditMode": "退出编辑模式"
},
"provider": {
"tabProvider": "供应商",
"tabUniversal": "统一供应商",
"noProviders": "还没有添加任何供应商",
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
"currentlyUsing": "当前使用",
@@ -340,6 +342,10 @@
"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 默认模型",
@@ -1057,6 +1063,9 @@
}
},
"switchFailed": "切换失败: {{error}}",
"failover": {
"proxyRequired": "需要先启动代理服务才能配置故障转移"
},
"failoverQueue": {
"title": "故障转移队列",
"description": "管理各应用的供应商故障转移顺序",
@@ -1128,5 +1137,49 @@
"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": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)"
}
}
File diff suppressed because one or more lines are too long
+14
View File
@@ -198,6 +198,13 @@ 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",
@@ -331,6 +338,13 @@ 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
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
export type { AppId } from "./types";
export { providersApi } from "./providers";
export { providersApi, universalProvidersApi } from "./providers";
export { settingsApi } from "./settings";
export { mcpApi } from "./mcp";
export { promptsApi } from "./prompts";
+46 -1
View File
@@ -1,6 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Provider } from "@/types";
import type {
Provider,
UniversalProvider,
UniversalProvidersMap,
} from "@/types";
import type { AppId } from "./types";
export interface ProviderSortUpdate {
@@ -62,3 +66,44 @@ export const providersApi = {
});
},
};
// ============================================================================
// 统一供应商(Universal ProviderAPI
// ============================================================================
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 });
},
};
+58
View File
@@ -186,3 +186,61 @@ 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>;
+172
View File
@@ -0,0 +1,172 @@
/** @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: [],
};
-175
View File
@@ -1,175 +0,0 @@
/** @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: [],
}
+8 -3
View File
@@ -9,7 +9,12 @@
"esModuleInterop": true,
"target": "ES2020",
"strict": true,
"types": ["node"]
"types": [
"node"
]
},
"include": ["vite.config.mts", "vitest.config.ts"]
}
"include": [
"vite.config.ts",
"vitest.config.ts"
]
}
+10 -3
View File
@@ -1,10 +1,17 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { codeInspectorPlugin } from "code-inspector-plugin";
export default defineConfig({
export default defineConfig(({ command }) => ({
root: "src",
plugins: [react()],
plugins: [
command === "serve" &&
codeInspectorPlugin({
bundler: "vite",
}),
react(),
].filter(Boolean),
base: "./",
build: {
outDir: "../dist",
@@ -21,4 +28,4 @@ export default defineConfig({
},
clearScreen: false,
envPrefix: ["VITE_", "TAURI_"],
});
}));