Compare commits

...

27 Commits

Author SHA1 Message Date
Jason 44ca688253 chore: bump version to 3.9.0-2 for second test release
- Update version in package.json, Cargo.toml, tauri.conf.json
- Fix clippy too_many_arguments warning in forwarder.rs
2025-12-20 18:10:45 +08:00
Jason 5fe5ed98be style(header): unify height and styling of header toolbar sections
- Use consistent h-8 fixed height for all inner elements
- Standardize border-radius to rounded-xl across all sections
- Remove background from ProxyToggle for cleaner appearance
- Simplify ProxyToggle structure with nested container
2025-12-20 16:24:25 +08:00
Jason b2a9e91d70 feat(providers): add DMXAPI as official partner
Mark DMXAPI as partner in both Claude and Codex presets with promotion
message for their Claude Code exclusive model 66% OFF offer.
2025-12-20 13:23:15 +08:00
Jason 4a1a997935 feat(icons): add provider icons for OpenRouter, LongCat, ModelScope, AiHubMix
- Add SVG icons for OpenRouter, LongCat, ModelScope, and AiHubMix
- Register icons in index.ts and metadata.ts with search keywords
- Link icons to corresponding provider presets in claudeProviderPresets.ts
2025-12-20 12:43:59 +08:00
Jason c4535c894a refactor(proxy): switch OpenRouter to passthrough mode for native Claude API
OpenRouter now supports Claude Code compatible endpoint (/v1/messages),
eliminating the need for Anthropic ↔ OpenAI format conversion.

- Disable format transformation for OpenRouter (keep old logic as fallback)
- Pass through original endpoint instead of redirecting to /v1/chat/completions
- Add anthropic-version header for ClaudeAuth and Bearer strategies
- Update tests to reflect new passthrough behavior
2025-12-20 12:13:39 +08:00
Jason 64e0cabaa7 fix(window): add minWidth/minHeight to Windows platform config
Tauri 2.0 platform config merging is shallow, not deep. The Windows
config only specified titleBarStyle, causing minWidth/minHeight to
be missing on Windows. This allowed users to resize the window below
900px, causing header elements to misalign.
2025-12-20 11:19:26 +08:00
Jason 8ecb41d25e fix(proxy): respect existing token field when syncing Claude config
- Add support for ANTHROPIC_API_KEY in Claude auth extraction
- Only update existing token fields during sync, avoid adding fields
  that weren't originally configured by the user
- Add tests for both scenarios
2025-12-20 11:04:07 +08:00
Jason 3e8f84481d fix(proxy): add fallback recovery for orphaned takeover state
- Detect takeover residue in Live configs even when proxy is not running
- Implement 3-tier fallback: backup → SSOT → cleanup placeholders
- Only delete backup after successful restore to prevent data loss
- Fix EditProviderDialog to check current app's takeover status only
2025-12-20 10:07:04 +08:00
Jason ba59483b33 refactor(proxy): remove global auto-start flag
- Remove global proxy auto-start flag from config and UI.
- Simplify per-app takeover start/stop and stop server when the last takeover is disabled.
- Restore live takeover detection used for crash recovery.
- Keep proxy_config.enabled column but always write 0 for compatibility.
- Tests: not run (not requested).
2025-12-20 08:48:59 +08:00
Jason b6ff721d67 fix(import): refresh all providers immediately after SQL import
- Remove setTimeout delay that could be cancelled on component unmount
- Invalidate all providers cache (not just current app) since import affects all apps
- Call onImportSuccess before sync to ensure UI refresh even if sync fails
- Update i18n: "Data refreshed" (past tense, reflecting immediate action)
2025-12-19 20:48:15 +08:00
Jason 1706c9a26f fix(backup): restrict SQL import to CC Switch exported backups only
- Add validation to reject SQL files without CC Switch export header
- Remove redundant sanitize_import_sql (sqlite_* objects already excluded at export time)
- Fix backup filename collision by appending counter suffix
- Update i18n hints to clarify import restriction
2025-12-19 20:48:15 +08:00
YoVinchen 5bce6d6020 Fix/about section UI (#419)
* fix(ui): improve AboutSection styling and version detection

- Add framer-motion animations for smooth page transitions
- Unify button sizes and add icons for consistency
- Add gradient backgrounds and hover effects to cards
- Add notInstalled i18n translations (zh/en/ja)
- Fix version detection when stdout/stderr is empty

* fix(proxy): persist per-app takeover state across app restarts

- Fix proxy toggle color to reflect current app's takeover state only
- Restore proxy service on startup if Live config is still in takeover state
- Preserve per-app backup records instead of clearing all on restart
- Only recover Live config when proxy service fails to start
2025-12-19 20:40:11 +08:00
Jason 6bdbb4df23 chore: update Cargo.lock for version 3.9.0-1 2025-12-18 23:41:45 +08:00
Jason 256903ee70 chore: rename version to 3.9.0-1 for MSI compatibility
MSI installer requires numeric-only pre-release identifiers.
Changed from 3.9.0-beta.1 to 3.9.0-1.
2025-12-18 22:02:41 +08:00
Jason f42f73ebb0 fix: import RunEvent for all platforms
The #[cfg(target_os = "macos")] restriction was a historical artifact
from when RunEvent was only used for macOS-specific events (Reopen, Opened).
After c9ea13a added ExitRequested handling for all platforms, the import
should have been updated but was overlooked.
2025-12-18 21:32:17 +08:00
Jason ec6e113cf2 chore: bump version to 3.9.0-beta.1
- Update version in package.json, Cargo.toml, tauri.conf.json
- Add CHANGELOG entry for v3.9.0-beta.1 with:
  - Local Proxy Server feature
  - Auto Failover with circuit breaker
  - Skills multi-app support
  - Provider icon colors
  - 25+ bug fixes
- Add proxy feature guide documentation (Chinese)
2025-12-18 20:53:02 +08:00
Jason ae837ade02 fix(ui): restore fade transition for Skills button
The Skills button transition was accidentally removed in commit 1fb2d5e
when adding multi-app support. This restores the smooth fade-in/out
animation when switching between apps that support Skills (Claude/Codex)
and those that don't (Gemini).
2025-12-18 17:34:16 +08:00
Jason a8fd1f0dd2 fix(mcp): skip sync when target CLI app is not installed
Add guard functions to check if Claude/Codex/Gemini CLI has been
initialized before attempting to sync MCP configurations. This prevents
creating unwanted config files in directories that don't exist.

- Claude: check ~/.claude dir OR ~/.claude.json file exists
- Codex: check ~/.codex dir exists
- Gemini: check ~/.gemini dir exists

When the target app is not installed, sync operations now silently
succeed without writing any files, allowing users to manage MCP servers
for apps they actually use without side effects on others.
2025-12-18 16:08:10 +08:00
Jason fa33330b3b fix(mcp): improve upsert and import robustness
- Remove server from live config when app is disabled during upsert
- Merge enabled flags instead of overwriting when importing from multiple apps
- Normalize Gemini MCP type field (url-only → sse, command → stdio)
- Use atomic write for Codex config updates
- Add tests for disable-removal, multi-app merge, and Gemini SSE import
2025-12-18 15:14:37 +08:00
Jason 18207771ad feat(proxy): implement per-app takeover mode
Replace global live takeover with granular per-app control:
- Add start_proxy_server command (start without takeover)
- Add get_proxy_takeover_status to query each app's state
- Add set_proxy_takeover_for_app for individual app control
- Use live backup existence as SSOT for takeover state
- Refactor sync_live_to_provider to eliminate code duplication
- Update ProxyToggle to show status per active app
2025-12-18 11:28:10 +08:00
Jason 0cd7d0756c fix(proxy): takeover Codex base_url via model_provider
- Update Codex `model_providers.<model_provider>.base_url` to the proxy origin with `/v1`
- Add route fallbacks for `/responses` and `/chat/completions` (plus double-`/v1` safeguard)
- Add unit tests for the TOML base_url takeover logic
2025-12-17 22:53:32 +08:00
Jason bca0997afa fix(proxy): harden crash recovery with fallback detection
- Set takeover flag before writing proxy config to fix race condition
  where crash during takeover left Live configs corrupted but flag unset
- Add fallback detection by checking for placeholder tokens in Live
  configs when backups exist but flag is false (handles legacy/edge cases)
- Improve error handling with proper rollback at each stage of startup
- Clean up stale backups when Live configs are not in takeover state
  to avoid long-term storage of sensitive tokens
2025-12-17 11:03:49 +08:00
Jason 0ef8a4153f fix(proxy): sync UI when active provider differs from current setting
Previously, UI sync was triggered only when failover happened (retry count > 1).
This missed cases where the first provider in the failover queue succeeded but
was different from the user's selected provider in settings.

Now we capture the current provider ID at request start and compare it with
the actually used provider. This ensures UI/tray always reflects the real
provider handling requests.
2025-12-17 10:22:02 +08:00
Jason 1b73b26c0e fix(proxy): resolve circuit breaker race condition and error classification
This commit addresses two critical issues in the proxy failover logic:

1. Circuit Breaker HalfOpen Concurrency Bug:
   - Introduced `AllowResult` struct to track half-open permit usage
   - Added state guard in `transition_to_half_open()` to prevent duplicate resets
   - Replaced `fetch_sub` with CAS loop in `release_half_open_permit()` to prevent underflow
   - Separated `is_available()` (routing) from `allow_request()` (permit acquisition)

2. Error Classification Conflation:
   - Split retry logic into `should_retry_same_provider()` and `categorize_proxy_error()`
   - Same-provider retry: only for transient errors (timeout, 429, 5xx)
   - Cross-provider failover: now includes ConfigError, TransformError, AuthError
   - 4xx errors (401/403) no longer waste retries on the same provider
2025-12-17 09:36:17 +08:00
Jason 3d514c8250 fix(proxy): stabilize live takeover and provider editing
- Skip live writes when takeover is active and proxy is running
- Refresh live backups from provider edits during takeover
- Sync live tokens to DB without clobbering real keys with placeholders
- Avoid injecting extra placeholder keys into Claude live env
- Reapply takeover after proxy listen address/port changes
- In takeover mode, edit dialog uses DB config and keeps API key state in sync
2025-12-17 09:36:17 +08:00
TinsFox 18e973b920 fix: azure website link (#407) 2025-12-17 08:43:55 +08:00
YoVinchen ec20ff4d8c Feature/error request logging (#401)
* feat(proxy): add error mapper for HTTP status code mapping

- Add error_mapper.rs module to map ProxyError to HTTP status codes
- Implement map_proxy_error_to_status() for error classification
- Implement get_error_message() for user-friendly error messages
- Support all error types: upstream, timeout, connection, provider failures
- Include comprehensive unit tests for all mappings

* feat(proxy): enhance error logging with context support

- Add log_error_with_context() method for detailed error recording
- Support streaming flag, session_id, and provider_type fields
- Remove dead_code warning from log_error() method
- Enable comprehensive error request tracking in database

* feat(proxy): implement error capture and logging in all handlers

- Capture and log all failed requests in handle_messages (Claude)
- Capture and log all failed requests in handle_gemini (Gemini)
- Capture and log all failed requests in handle_responses (Codex)
- Capture and log all failed requests in handle_chat_completions (Codex)
- Record error status codes, messages, and latency for all failures
- Generate unique session_id for each request
- Support both streaming and non-streaming error scenarios

* style: fix clippy warnings and typescript errors

- Add allow(dead_code) for CircuitBreaker::get_state (reserved for future)
- Fix all uninlined format string warnings (27 instances)
- Use inline format syntax for better readability
- Fix unused import and parameter warnings in ProviderActions.tsx
- Achieve zero warnings in both Rust and TypeScript

* style: apply code formatting

- Remove trailing whitespace in misc.rs
- Add trailing comma in App.tsx
- Format multi-line className in ProviderCard.tsx

* feat(proxy): add settings button to proxy panel

Add configuration buttons in both running and stopped states to
provide easy access to proxy settings dialog.

* fix(speedtest): skip client build for invalid inputs

* chore(clippy): fix uninlined format args

* Merge branch 'main' into feature/error-request-logging
2025-12-16 21:02:08 +08:00
62 changed files with 3287 additions and 712 deletions
+94
View File
@@ -5,6 +5,100 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.9.0-1] - 2025-12-18
### Beta Release
This beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.
### Major Features
#### Local Proxy Server
- **Local HTTP Proxy** - High-performance proxy server built on Axum framework
- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests
- **Per-app Takeover** - Independent control over which apps route through the proxy
- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy
#### Auto Failover
- **Circuit Breaker** - Automatically detects provider failures and triggers protection
- **Smart Failover** - Automatically switches to backup provider when current one is unavailable
- **Health Tracking** - Real-time monitoring of provider availability
- **Independent Failover Queues** - Each app maintains its own failover queue
#### Monitoring
- **Request Logging** - Detailed logging of all proxy requests
- **Usage Statistics** - Token consumption, latency, success rate metrics
- **Real-time Status** - Frontend displays proxy status and statistics
#### Skills Multi-App Support
- **Multi-app Support** - Skills now support both Claude and Codex (#365)
- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)
- **Installation Path Fix** - Use directory basename for skill installation path (#358)
### Added
- **Provider Icon Colors** - Customize provider icon colors (#385)
- **Deeplink Usage Config** - Import usage query config via deeplink (#400)
- **Error Request Logging** - Detailed logging for proxy requests (#401)
- **Closable Toast** - Added close button to switch notification toast (#350)
- **Icon Color Component** - ProviderIcon component supports color prop (#384)
### Fixed
#### Proxy Related
- Takeover Codex base_url via model_provider
- Harden crash recovery with fallback detection
- Sync UI when active provider differs from current setting
- Resolve circuit breaker race condition and error classification
- Stabilize live takeover and provider editing
- Reset health badges when proxy stops
- Retry failover for all HTTP errors including 4xx
- Fix HalfOpen counter underflow and config field inconsistencies
- Resolve circuit breaker state persistence and HalfOpen deadlock
- Auto-recover live config after abnormal exit
- Update live backup when hot-switching provider in proxy mode
- Wait for server shutdown before exiting app
- Disable auto-start on app launch by resetting enabled flag on stop
- Sync live config tokens to database before takeover
- Resolve 404 error and auto-setup proxy targets
#### MCP Related
- Skip sync when target CLI app is not installed
- Improve upsert and import robustness
- Use browser-compatible platform detection for MCP presets
#### UI Related
- Restore fade transition for Skills button
- Add close button to all success toasts
- Prevent card jitter when health badge appears
- Update SettingsPage tab styles (#342)
#### Other
- Fix Azure website link (#407)
- Add fallback to provider config for usage credentials (#360)
- Fix Windows black screen on startup (use system titlebar)
- Add fallback for crypto.randomUUID() on older WebViews
- Use correct npm package for Codex CLI version check
- Security fixes for JavaScript executor and usage script (#151)
### Improved
- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active
- **Card Animation** - Improved provider card hover animation
- **Remove Restart Prompt** - No longer prompts restart when switching providers
### Technical
- Implement per-app takeover mode
- Proxy module contains 20+ Rust files with complete layered architecture
- Add 5 new database tables for proxy functionality
- Modularize handlers.rs to reduce code duplication
- Remove is_proxy_target in favor of failover_queue
### Stats
- 55 commits since v3.8.2
- 164 files changed
- +22,164 / -570 lines
---
## [3.8.0] - 2025-11-28 ## [3.8.0] - 2025-11-28
### Major Updates ### Major Updates
+165
View File
@@ -0,0 +1,165 @@
# CC Switch 代理功能使用指南
## 功能介绍
CC Switch 的代理功能是一个本地 HTTP 代理服务器,可以统一管理 Claude Code、Codex 和 Gemini CLI 的 API 请求。主要特性包括:
- **统一代理入口** - 所有 CLI 应用的请求通过本地代理转发
- **自动故障转移** - 当前供应商故障时自动切换到备用供应商
- **按应用控制** - 可独立控制每个应用是否启用代理
- **配置保护** - 自动备份原始配置,停止代理时安全恢复
## 快速开始
### 1. 启动代理
在 CC Switch 主界面,点击右上角的 **Proxy** 按钮,可以看到代理控制面板。
点击 **启动代理** 按钮启动本地代理服务器。代理默认监听 `127.0.0.1:15721`
### 2. 启用应用接管
代理启动后,你可以选择让哪些应用的请求通过代理:
- **Claude** - 接管 Claude Code 的 API 请求
- **Codex** - 接管 Codex CLI 的 API 请求
- **Gemini** - 接管 Gemini CLI 的 API 请求
点击对应应用的开关即可启用/禁用接管。
> **注意**:启用接管后,CC Switch 会自动修改对应应用的配置文件,将 API 端点指向本地代理。原始配置会被安全备份。
### 3. 正常使用 CLI
启用接管后,你可以正常使用各个 CLI 工具。所有请求都会经过 CC Switch 代理转发到配置的供应商。
### 4. 停止代理
当你不再需要代理时,点击 **停止代理** 按钮。CC Switch 会:
1. 安全关闭代理服务器
2. 自动恢复所有应用的原始配置
3. 清除代理状态
## 自动故障转移
### 工作原理
代理功能内置了智能故障转移机制:
1. **健康监控** - 实时监控每个供应商的响应状态
2. **熔断器** - 连续失败 5 次后触发熔断,暂停使用该供应商
3. **自动切换** - 熔断后自动切换到列表中的下一个供应商
4. **自动恢复** - 30 秒后尝试恢复熔断的供应商
### 配置故障转移
要使用故障转移功能,你需要:
1. 在对应应用下添加多个供应商(至少 2 个)
2. 启动代理并启用接管
3. 当主供应商故障时,代理会自动切换到备用供应商
### 健康状态指示
在供应商卡片上可以看到健康状态指示:
- **绿色** - 供应商正常
- **红色** - 供应商故障/熔断中
- **灰色** - 未使用代理或未检测
## 按应用接管
v3.9.0 新增了按应用分粒度控制功能:
- 你可以只接管 Claude,而让 Codex 使用原始配置
- 每个应用的接管状态独立管理
- 启用/禁用不会影响其他应用
### 接管状态检测
CC Switch 通过检测配置备份来判断接管状态:
- 存在备份 = 已接管
- 无备份 = 未接管
这确保了即使 CC Switch 异常退出,重新启动后也能正确识别状态。
## 代理配置
在代理面板中,你可以配置以下参数:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| 监听地址 | 127.0.0.1 | 代理服务器绑定地址 |
| 监听端口 | 15721 | 代理服务器端口 |
| 最大重试 | 3 | 请求失败时的最大重试次数 |
| 请求超时 | 120 秒 | 单个请求的超时时间 |
| 启用日志 | 是 | 是否记录请求日志 |
## 常见问题
### Q: 代理启动失败,提示端口被占用?
A: 默认端口 15721 可能被其他程序占用。你可以:
- 关闭占用该端口的程序
- 在代理配置中修改端口号
### Q: 启用接管后 CLI 无法使用?
A: 请检查:
1. 代理服务器是否正常运行(查看代理面板状态)
2. 供应商配置是否正确(API Key 等)
3. 网络连接是否正常
### Q: 如何恢复原始配置?
A: 点击 **停止代理** 按钮,CC Switch 会自动恢复所有应用的原始配置。
如果 CC Switch 异常退出,重新启动后会检测到之前的备份,你可以:
- 点击停止代理来恢复配置
- 或继续使用代理功能
### Q: 故障转移没有生效?
A: 请确保:
1. 配置了至少 2 个供应商
2. 代理已启动且接管已启用
3. 故障转移只在代理模式下工作
### Q: 代理会影响性能吗?
A: 本地代理的延迟开销非常小(通常 < 1ms)。但如果启用了请求日志,在高频请求场景下可能会有少量性能影响。
## 技术细节
### 配置文件位置
启用接管后,CC Switch 会修改以下配置文件:
| 应用 | 配置文件 | 修改内容 |
|------|----------|----------|
| Claude | `~/.claude/settings.json` | `apiBaseUrl` 指向代理 |
| Codex | `~/.codex/config.toml` | `[api] baseUrl` 指向代理 |
| Gemini | `~/.gemini/.env` | `GEMINI_BASE_URL` 指向代理 |
原始配置备份在 CC Switch 数据库中,停止代理时自动恢复。
### 代理模式
代理服务器运行在接管模式下,会:
1. 接收来自 CLI 的 HTTPS 请求
2. 根据当前供应商配置转发到真实 API 端点
3. 返回响应给 CLI
4. 记录请求日志和健康状态
### 数据库表
代理功能使用以下数据库表:
- `proxy_config` - 代理配置
- `provider_health` - 供应商健康状态
- `proxy_request_logs` - 请求日志
- `circuit_breaker_config` - 熔断器配置
- `proxy_live_backup` - Live 配置备份
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cc-switch", "name": "cc-switch",
"version": "3.8.2", "version": "3.9.0-2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": { "scripts": {
"dev": "pnpm tauri dev", "dev": "pnpm tauri dev",
+1 -1
View File
@@ -695,7 +695,7 @@ dependencies = [
[[package]] [[package]]
name = "cc-switch" name = "cc-switch"
version = "3.8.2" version = "3.9.0-2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cc-switch" name = "cc-switch"
version = "3.8.2" version = "3.9.0-2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"] authors = ["Jason Young"]
license = "MIT" license = "MIT"
+15 -5
View File
@@ -155,11 +155,17 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
match output { match output {
Ok(out) => { Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() { if out.status.success() {
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); let raw = if stdout.is_empty() { &stderr } else { &stdout };
(Some(extract_version(&raw)), None) if raw.is_empty() {
(None, Some("未安装或无法执行".to_string()))
} else { } else {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); (Some(extract_version(raw)), None)
}
} else {
let err = if stderr.is_empty() { stdout } else { stderr };
( (
None, None,
Some(if err.is_empty() { Some(if err.is_empty() {
@@ -239,9 +245,13 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
.output(); .output();
if let Ok(out) = output { if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if out.status.success() { if out.status.success() {
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); let raw = if stdout.is_empty() { &stderr } else { &stdout };
return (Some(extract_version(&raw)), None); if !raw.is_empty() {
return (Some(extract_version(raw)), None);
}
} }
} }
} }
+29
View File
@@ -6,6 +6,14 @@ use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState; use crate::store::AppState;
/// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command]
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await
}
/// 启动代理服务器(带 Live 配置接管) /// 启动代理服务器(带 Live 配置接管)
#[tauri::command] #[tauri::command]
pub async fn start_proxy_with_takeover( pub async fn start_proxy_with_takeover(
@@ -20,6 +28,27 @@ pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Resul
state.proxy_service.stop_with_restore().await state.proxy_service.stop_with_restore().await
} }
/// 获取各应用接管状态
#[tauri::command]
pub async fn get_proxy_takeover_status(
state: tauri::State<'_, AppState>,
) -> Result<ProxyTakeoverStatus, String> {
state.proxy_service.get_takeover_status().await
}
/// 为指定应用开启/关闭接管
#[tauri::command]
pub async fn set_proxy_takeover_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
enabled: bool,
) -> Result<(), String> {
state
.proxy_service
.set_takeover_for_app(&app_type, enabled)
.await
}
/// 获取代理服务器状态 /// 获取代理服务器状态
#[tauri::command] #[tauri::command]
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> { pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
+23 -22
View File
@@ -13,6 +13,8 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
impl Database { impl Database {
/// 导出为 SQLite 兼容的 SQL 文本 /// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> { pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
@@ -36,7 +38,8 @@ impl Database {
} }
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?; let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = Self::sanitize_import_sql(&sql_raw); let sql_content = sql_raw.trim_start_matches('\u{feff}');
Self::validate_cc_switch_sql_export(sql_content)?;
// 导入前备份现有数据库 // 导入前备份现有数据库
let backup_path = self.backup_database_file()?; let backup_path = self.backup_database_file()?;
@@ -51,7 +54,7 @@ impl Database {
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?; Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn temp_conn
.execute_batch(&sql_content) .execute_batch(sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?; .map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验 // 补齐缺失表/索引并进行基础校验
@@ -93,26 +96,17 @@ impl Database {
Ok(snapshot) Ok(snapshot)
} }
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错 fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
fn sanitize_import_sql(sql: &str) -> String { let trimmed = sql.trim_start();
let mut cleaned = String::new(); if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
let lower_keyword = "sqlite_sequence"; return Ok(());
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
} }
if trimmed.to_ascii_lowercase().contains(lower_keyword) { Err(AppError::localized(
continue; "backup.sql.invalid_format",
} "仅支持导入由 CC Switch 导出的 SQL 备份文件。",
"Only SQL backups exported by CC Switch are supported.",
cleaned.push_str(trimmed); ))
cleaned.push_str(";\n");
}
cleaned
} }
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
@@ -129,8 +123,15 @@ impl Database {
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let backup_path = backup_dir.join(format!("{backup_id}.db")); let mut backup_id = base_id.clone();
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
let mut counter = 1;
while backup_path.exists() {
backup_id = format!("{base_id}_{counter}");
backup_path = backup_dir.join(format!("{backup_id}.db"));
counter += 1;
}
{ {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
+2 -9
View File
@@ -69,11 +69,7 @@ impl Database {
} }
/// 添加供应商到故障转移队列末尾 /// 添加供应商到故障转移队列末尾
pub fn add_to_failover_queue( pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order // 获取当前最大 queue_order
@@ -199,10 +195,7 @@ impl Database {
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> { pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.execute( conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
"DELETE FROM failover_queue WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
+21 -18
View File
@@ -16,19 +16,18 @@ impl Database {
let result = { let result = {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
"SELECT enabled, listen_address, listen_port, max_retries, "SELECT listen_address, listen_port, max_retries,
request_timeout, enable_logging, live_takeover_active request_timeout, enable_logging, live_takeover_active
FROM proxy_config WHERE id = 1", FROM proxy_config WHERE id = 1",
[], [],
|row| { |row| {
Ok(ProxyConfig { Ok(ProxyConfig {
enabled: row.get::<_, i32>(0)? != 0, listen_address: row.get(0)?,
listen_address: row.get(1)?, listen_port: row.get::<_, i32>(1)? as u16,
listen_port: row.get::<_, i32>(2)? as u16, max_retries: row.get::<_, i32>(2)? as u8,
max_retries: row.get::<_, i32>(3)? as u8, request_timeout: row.get::<_, i32>(3)? as u64,
request_timeout: row.get::<_, i32>(4)? as u64, enable_logging: row.get::<_, i32>(4)? != 0,
enable_logging: row.get::<_, i32>(5)? != 0, live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0,
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
}) })
}, },
) )
@@ -57,7 +56,7 @@ impl Database {
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))", datetime('now'))",
rusqlite::params![ rusqlite::params![
if config.enabled { 1 } else { 0 }, 0, // 已移除自动启用逻辑,保留列但固定为 0
config.listen_address, config.listen_address,
config.listen_port as i32, config.listen_port as i32,
config.max_retries as i32, config.max_retries as i32,
@@ -85,15 +84,8 @@ impl Database {
/// 检查是否处于 Live 接管模式 /// 检查是否处于 Live 接管模式
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> { pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn); // v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
let active: i32 = conn self.has_any_live_backup().await
.query_row(
"SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(active != 0)
} }
// ==================== Provider Health ==================== // ==================== Provider Health ====================
@@ -321,6 +313,17 @@ impl Database {
Ok(()) Ok(())
} }
/// 检查是否存在任意 Live 配置备份
pub async fn has_any_live_backup(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_live_backup", [], |row| {
row.get(0)
})
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 获取 Live 配置备份 /// 获取 Live 配置备份
pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> { pub async fn get_live_backup(&self, app_type: &str) -> Result<Option<LiveBackup>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
+11 -4
View File
@@ -42,8 +42,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
/// ///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性: /// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
/// - httpUrl → url + type: "http" /// - httpUrl → url + type: "http"
/// - 仅有 url 字段 → 保持不变(SSE 类型) /// - 仅有 url 字段 → 补齐 type: "sse"Gemini 以字段名推断传输类型)
/// - 仅有 command 字段 → 保持不变(stdio 类型) /// - 仅有 command 字段 → 补齐 type: "stdio"
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> { pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path(); let path = user_config_path();
if !path.exists() { if !path.exists() {
@@ -65,8 +65,15 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
obj.insert("url".to_string(), http_url); obj.insert("url".to_string(), http_url);
obj.insert("type".to_string(), Value::String("http".to_string())); obj.insert("type".to_string(), Value::String("http".to_string()));
} }
// 如果有 url 但没有 type,不添加 type(默认为 SSE
// 如果有 command 但没有 type,不添加 type(默认为 stdio // Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入
if obj.get("type").is_none() {
if obj.contains_key("command") {
obj.insert("type".to_string(), Value::String("stdio".to_string()));
} else if obj.contains_key("url") {
obj.insert("type".to_string(), Value::String("sse".to_string()));
}
}
} }
} }
+37 -50
View File
@@ -49,7 +49,6 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc; use std::sync::Arc;
use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::RunEvent; use tauri::RunEvent;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -333,9 +332,7 @@ pub fn run() {
let app_state = AppState::new(db); let app_state = AppState::new(db);
// 设置 AppHandle 用于代理故障转移时的 UI 更新 // 设置 AppHandle 用于代理故障转移时的 UI 更新
app_state app_state.proxy_service.set_app_handle(app.handle().clone());
.proxy_service
.set_app_handle(app.handle().clone());
// ============================================================ // ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响) // 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
@@ -527,49 +524,30 @@ pub fn run() {
} }
} }
// 异常退出恢复 + 自动启动代理服务器 // 异常退出恢复
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
// 1. 检测异常退出并恢复 Live 配置 // 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
match state.db.is_live_takeover_active().await { let has_backups = match state.db.has_any_live_backup().await {
Ok(true) => { Ok(v) => v,
// 接管标志为 true 但代理未运行 → 上次异常退出 Err(e) => {
if !state.proxy_service.is_running().await { log::error!("检查 Live 备份失败: {e}");
log::warn!("检测到上次异常退出,正在恢复 Live 配置..."); false
}
};
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
if has_backups || live_taken_over {
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
if let Err(e) = state.proxy_service.recover_from_crash().await { if let Err(e) = state.proxy_service.recover_from_crash().await {
log::error!("恢复 Live 配置失败: {e}"); log::error!("恢复 Live 配置失败: {e}");
} else { } else {
log::info!("Live 配置已从异常退出中恢复"); log::info!("Live 配置已恢复");
} }
} }
}
Ok(false) => {
// 正常状态,无需恢复
}
Err(e) => {
log::error!("检查接管状态失败: {e}");
}
}
// 2. 自动启动代理服务器(如果配置为启用)
match state.db.get_proxy_config().await {
Ok(config) => {
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start_with_takeover().await {
Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}",
info.address,
info.port
),
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
}
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
}
}); });
Ok(()) Ok(())
@@ -674,8 +652,11 @@ pub fn run() {
commands::set_auto_launch, commands::set_auto_launch,
commands::get_auto_launch_status, commands::get_auto_launch_status,
// Proxy server management // Proxy server management
commands::start_proxy_server,
commands::start_proxy_with_takeover, commands::start_proxy_with_takeover,
commands::stop_proxy_with_restore, commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
commands::get_proxy_status, commands::get_proxy_status,
commands::get_proxy_config, commands::get_proxy_config,
commands::update_proxy_config, commands::update_proxy_config,
@@ -830,27 +811,33 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
if let Some(state) = app_handle.try_state::<store::AppState>() { if let Some(state) = app_handle.try_state::<store::AppState>() {
let proxy_service = &state.proxy_service; let proxy_service = &state.proxy_service;
// 检查代理是否在运行 // 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。
if proxy_service.is_running().await { let has_backups = match state.db.has_any_live_backup().await {
log::info!("检测到代理服务器正在运行,开始清理..."); Ok(v) => v,
Err(e) => {
log::error!("退出时检查 Live 备份失败: {e}");
false
}
};
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
let needs_restore = has_backups || live_taken_over;
// 检查是否处于 Live 接管模式 if needs_restore {
if let Ok(is_takeover) = state.db.is_live_takeover_active().await { log::info!("检测到接管残留,开始恢复 Live 配置...");
if is_takeover {
// 接管模式:停止并恢复配置
if let Err(e) = proxy_service.stop_with_restore().await { if let Err(e) = proxy_service.stop_with_restore().await {
log::error!("退出时恢复 Live 配置失败: {e}"); log::error!("退出时恢复 Live 配置失败: {e}");
} else { } else {
log::info!("已恢复 Live 配置"); log::info!("已恢复 Live 配置");
} }
} else { return;
// 非接管模式:仅停止代理 }
// 非接管模式:代理在运行则仅停止代理
if proxy_service.is_running().await {
log::info!("检测到代理服务器正在运行,开始停止...");
if let Err(e) = proxy_service.stop().await { if let Err(e) = proxy_service.stop().await {
log::error!("退出时停止代理失败: {e}"); log::error!("退出时停止代理失败: {e}");
} }
}
}
log::info!("代理服务器清理完成"); log::info!("代理服务器清理完成");
} }
} }
+15
View File
@@ -8,6 +8,12 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_claude_mcp() -> bool {
// Claude 未安装/未初始化时:通常 ~/.claude 目录与 ~/.claude.json 都不存在。
// 按用户偏好:此时跳过写入/删除,不创建任何文件或目录。
crate::config::get_claude_config_dir().exists() || crate::config::get_claude_mcp_path().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -33,6 +39,9 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json /// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.claude); let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled) crate::claude_mcp::set_mcp_servers_map(&enabled)
} }
@@ -107,6 +116,9 @@ pub fn sync_single_server_to_claude(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?; let current = crate::claude_mcp::read_mcp_servers_map()?;
@@ -120,6 +132,9 @@ pub fn sync_single_server_to_claude(
/// 从 Claude live 配置中移除单个 MCP 服务器 /// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> { pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
if !should_sync_claude_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?; let mut current = crate::claude_mcp::read_mcp_servers_map()?;
+19 -2
View File
@@ -13,6 +13,12 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_codex_mcp() -> bool {
// Codex 未安装/未初始化时:~/.codex 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::codex_config::get_codex_config_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -273,6 +279,9 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
/// - 仅更新 `mcp_servers` 表,保留其它键 /// - 仅更新 `mcp_servers` 表,保留其它键
/// - 仅写入启用项;无启用项时清理 mcp_servers 表 /// - 仅写入启用项;无启用项时清理 mcp_servers 表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::{Item, Table}; use toml_edit::{Item, Table};
// 1) 收集启用项(Codex 维度) // 1) 收集启用项(Codex 维度)
@@ -339,6 +348,9 @@ pub fn sync_single_server_to_codex(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::Item; use toml_edit::Item;
// 读取现有的 config.toml // 读取现有的 config.toml
@@ -376,7 +388,8 @@ pub fn sync_single_server_to_codex(
doc["mcp_servers"][id] = Item::Table(toml_table); doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件 // 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; let new_text = doc.to_string();
crate::config::write_text_file(&config_path, &new_text)?;
Ok(()) Ok(())
} }
@@ -384,6 +397,9 @@ pub fn sync_single_server_to_codex(
/// 从 Codex live 配置中移除单个 MCP 服务器 /// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据 /// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
if !should_sync_codex_mcp() {
return Ok(());
}
let config_path = crate::codex_config::get_codex_config_path(); let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() { if !config_path.exists() {
@@ -412,7 +428,8 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
} }
// 写回文件 // 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; let new_text = doc.to_string();
crate::config::write_text_file(&config_path, &new_text)?;
Ok(()) Ok(())
} }
+15
View File
@@ -8,6 +8,12 @@ use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec}; use super::validation::{extract_server_spec, validate_server_spec};
fn should_sync_gemini_mcp() -> bool {
// Gemini 未安装/未初始化时:~/.gemini 目录不存在。
// 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
crate::gemini_config::get_gemini_dir().exists()
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true /// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> { fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new(); let mut out = HashMap::new();
@@ -33,6 +39,9 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置 /// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
let enabled = collect_enabled_servers(&config.mcp.gemini); let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled) crate::gemini_mcp::set_mcp_servers_map(&enabled)
} }
@@ -103,6 +112,9 @@ pub fn sync_single_server_to_gemini(
id: &str, id: &str,
server_spec: &Value, server_spec: &Value,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?; let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
@@ -115,6 +127,9 @@ pub fn sync_single_server_to_gemini(
/// 从 Gemini live 配置中移除单个 MCP 服务器 /// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> { pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
if !should_sync_gemini_mcp() {
return Ok(());
}
// 读取现有的 MCP 配置 // 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?; let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
+138 -52
View File
@@ -78,6 +78,16 @@ pub struct CircuitBreaker {
half_open_requests: Arc<AtomicU32>, half_open_requests: Arc<AtomicU32>,
} }
/// 熔断器放行结果
///
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
#[derive(Debug, Clone, Copy)]
pub struct AllowResult {
pub allowed: bool,
pub used_half_open_permit: bool,
}
impl CircuitBreaker { impl CircuitBreaker {
/// 创建新的熔断器 /// 创建新的熔断器
pub fn new(config: CircuitBreakerConfig) -> Self { pub fn new(config: CircuitBreakerConfig) -> Self {
@@ -130,13 +140,16 @@ impl CircuitBreaker {
} }
/// 检查是否允许请求通过 /// 检查是否允许请求通过
pub async fn allow_request(&self) -> bool { pub async fn allow_request(&self) -> AllowResult {
let state = *self.state.read().await; let state = *self.state.read().await;
let config = self.config.read().await;
match state { match state {
CircuitState::Closed => true, CircuitState::Closed => AllowResult {
allowed: true,
used_half_open_permit: false,
},
CircuitState::Open => { CircuitState::Open => {
let config = self.config.read().await;
// 检查是否应该尝试半开 // 检查是否应该尝试半开
if let Some(opened_at) = *self.last_opened_at.read().await { if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds { if opened_at.elapsed().as_secs() >= config.timeout_seconds {
@@ -145,53 +158,47 @@ impl CircuitBreaker {
"Circuit breaker transitioning from Open to HalfOpen (timeout reached)" "Circuit breaker transitioning from Open to HalfOpen (timeout reached)"
); );
self.transition_to_half_open().await; self.transition_to_half_open().await;
// 增加计数,确保 record_success/record_failure 减计数时不会下溢
self.half_open_requests.fetch_add(1, Ordering::SeqCst);
return true;
}
}
false
}
CircuitState::HalfOpen => {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests { // 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
log::debug!( let current_state = *self.state.read().await;
"Circuit breaker HalfOpen: allowing probe request ({}/{})", return match current_state {
current + 1, CircuitState::Closed => AllowResult {
max_half_open_requests allowed: true,
); used_half_open_permit: false,
true },
} else { CircuitState::HalfOpen => self.allow_half_open_probe(),
// 超过限额,回退计数,拒绝请求 CircuitState::Open => AllowResult {
self.half_open_requests.fetch_sub(1, Ordering::SeqCst); allowed: false,
log::debug!( used_half_open_permit: false,
"Circuit breaker HalfOpen: rejecting request (limit reached: {})", },
max_half_open_requests };
);
false
} }
} }
AllowResult {
allowed: false,
used_half_open_permit: false,
}
}
CircuitState::HalfOpen => self.allow_half_open_probe(),
} }
} }
/// 记录成功 /// 记录成功
pub async fn record_success(&self) { pub async fn record_success(&self, used_half_open_permit: bool) {
let state = *self.state.read().await; let state = *self.state.read().await;
let config = self.config.read().await; let config = self.config.read().await;
if used_half_open_permit {
self.release_half_open_permit();
}
// 重置失败计数 // 重置失败计数
self.consecutive_failures.store(0, Ordering::SeqCst); self.consecutive_failures.store(0, Ordering::SeqCst);
self.total_requests.fetch_add(1, Ordering::SeqCst); self.total_requests.fetch_add(1, Ordering::SeqCst);
match state { match state {
CircuitState::HalfOpen => { CircuitState::HalfOpen => {
// 释放 in-flight 名额(探测请求结束)
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1; let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
log::debug!( log::debug!(
"Circuit breaker HalfOpen: {} consecutive successes (threshold: {})", "Circuit breaker HalfOpen: {} consecutive successes (threshold: {})",
@@ -213,10 +220,14 @@ impl CircuitBreaker {
} }
/// 记录失败 /// 记录失败
pub async fn record_failure(&self) { pub async fn record_failure(&self, used_half_open_permit: bool) {
let state = *self.state.read().await; let state = *self.state.read().await;
let config = self.config.read().await; let config = self.config.read().await;
if used_half_open_permit {
self.release_half_open_permit();
}
// 更新计数器 // 更新计数器
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1; let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
self.total_requests.fetch_add(1, Ordering::SeqCst); self.total_requests.fetch_add(1, Ordering::SeqCst);
@@ -235,13 +246,8 @@ impl CircuitBreaker {
// 检查是否应该打开熔断器 // 检查是否应该打开熔断器
match state { match state {
CircuitState::HalfOpen => { CircuitState::HalfOpen => {
// 释放 in-flight 名额(探测请求结束)
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
// HalfOpen 状态下失败,立即转为 Open // HalfOpen 状态下失败,立即转为 Open
log::warn!( log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
"Circuit breaker HalfOpen probe failed, transitioning to Open"
);
drop(config); drop(config);
self.transition_to_open().await; self.transition_to_open().await;
} }
@@ -286,6 +292,7 @@ impl CircuitBreaker {
} }
/// 获取当前状态 /// 获取当前状态
#[allow(dead_code)]
pub async fn get_state(&self) -> CircuitState { pub async fn get_state(&self) -> CircuitState {
*self.state.read().await *self.state.read().await
} }
@@ -309,6 +316,56 @@ impl CircuitBreaker {
self.transition_to_closed().await; self.transition_to_closed().await;
} }
fn allow_half_open_probe(&self) -> AllowResult {
// 半开状态限流:只允许有限请求通过进行探测
// 默认最多允许 1 个请求(可在配置中扩展)
let max_half_open_requests = 1u32;
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
if current < max_half_open_requests {
log::debug!(
"Circuit breaker HalfOpen: allowing probe request ({}/{})",
current + 1,
max_half_open_requests
);
AllowResult {
allowed: true,
used_half_open_permit: true,
}
} else {
// 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
);
AllowResult {
allowed: false,
used_half_open_permit: false,
}
}
}
fn release_half_open_permit(&self) {
let mut current = self.half_open_requests.load(Ordering::SeqCst);
loop {
if current == 0 {
// 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致
log::debug!("Circuit breaker HalfOpen permit already released (counter=0)");
return;
}
match self.half_open_requests.compare_exchange(
current,
current - 1,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return,
Err(actual) => current = actual,
}
}
}
/// 转换到打开状态 /// 转换到打开状态
async fn transition_to_open(&self) { async fn transition_to_open(&self) {
*self.state.write().await = CircuitState::Open; *self.state.write().await = CircuitState::Open;
@@ -319,7 +376,12 @@ impl CircuitBreaker {
/// 转换到半开状态 /// 转换到半开状态
async fn transition_to_half_open(&self) { async fn transition_to_half_open(&self) {
*self.state.write().await = CircuitState::HalfOpen; let mut state = self.state.write().await;
if *state != CircuitState::Open {
return;
}
*state = CircuitState::HalfOpen;
self.consecutive_successes.store(0, Ordering::SeqCst); self.consecutive_successes.store(0, Ordering::SeqCst);
// 重置半开状态的请求限流计数 // 重置半开状态的请求限流计数
self.half_open_requests.store(0, Ordering::SeqCst); self.half_open_requests.store(0, Ordering::SeqCst);
@@ -361,16 +423,16 @@ mod tests {
// 初始状态应该是关闭 // 初始状态应该是关闭
assert_eq!(breaker.get_state().await, CircuitState::Closed); assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await); assert!(breaker.allow_request().await.allowed);
// 记录 3 次失败 // 记录 3 次失败
for _ in 0..3 { for _ in 0..3 {
breaker.record_failure().await; breaker.record_failure(false).await;
} }
// 应该转换到打开状态 // 应该转换到打开状态
assert_eq!(breaker.get_state().await, CircuitState::Open); assert_eq!(breaker.get_state().await, CircuitState::Open);
assert!(!breaker.allow_request().await); assert!(!breaker.allow_request().await.allowed);
} }
#[tokio::test] #[tokio::test]
@@ -383,8 +445,8 @@ mod tests {
let breaker = CircuitBreaker::new(config); let breaker = CircuitBreaker::new(config);
// 打开熔断器 // 打开熔断器
breaker.record_failure().await; breaker.record_failure(false).await;
breaker.record_failure().await; breaker.record_failure(false).await;
assert_eq!(breaker.get_state().await, CircuitState::Open); assert_eq!(breaker.get_state().await, CircuitState::Open);
// 手动转换到半开状态 // 手动转换到半开状态
@@ -392,13 +454,37 @@ mod tests {
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen); assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 记录 2 次成功 // 记录 2 次成功
breaker.record_success().await; breaker.record_success(false).await;
breaker.record_success().await; breaker.record_success(false).await;
// 应该转换到关闭状态 // 应该转换到关闭状态
assert_eq!(breaker.get_state().await, CircuitState::Closed); assert_eq!(breaker.get_state().await, CircuitState::Closed);
} }
#[tokio::test]
async fn test_half_open_transition_does_not_reset_inflight_permit() {
let config = CircuitBreakerConfig {
timeout_seconds: 0,
..Default::default()
};
let breaker = CircuitBreaker::new(config);
// 进入 Open,然后由于 timeout_seconds=0allow_request 会立即切换到 HalfOpen 并占用探测名额
breaker.transition_to_open().await;
let first = breaker.allow_request().await;
assert!(first.allowed);
assert!(first.used_half_open_permit);
assert_eq!(breaker.get_state().await, CircuitState::HalfOpen);
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
breaker.transition_to_half_open().await;
// 由于名额仍被占用,第二次请求应被拒绝
let second = breaker.allow_request().await;
assert!(!second.allowed);
assert!(!second.used_half_open_permit);
}
#[tokio::test] #[tokio::test]
async fn test_circuit_breaker_reset() { async fn test_circuit_breaker_reset() {
let config = CircuitBreakerConfig { let config = CircuitBreakerConfig {
@@ -408,13 +494,13 @@ mod tests {
let breaker = CircuitBreaker::new(config); let breaker = CircuitBreaker::new(config);
// 打开熔断器 // 打开熔断器
breaker.record_failure().await; breaker.record_failure(false).await;
breaker.record_failure().await; breaker.record_failure(false).await;
assert_eq!(breaker.get_state().await, CircuitState::Open); assert_eq!(breaker.get_state().await, CircuitState::Open);
// 重置 // 重置
breaker.reset().await; breaker.reset().await;
assert_eq!(breaker.get_state().await, CircuitState::Closed); assert_eq!(breaker.get_state().await, CircuitState::Closed);
assert!(breaker.allow_request().await); assert!(breaker.allow_request().await.allowed);
} }
} }
+110
View File
@@ -0,0 +1,110 @@
//! 错误类型到 HTTP 状态码的映射
//!
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
use super::ProxyError;
/// 将 ProxyError 映射到 HTTP 状态码
///
/// 映射规则:
/// - 上游错误:直接使用上游返回的状态码
/// - 超时:504 Gateway Timeout
/// - 连接失败:502 Bad Gateway
/// - 无可用 Provider503 Service Unavailable
/// - 重试耗尽:503 Service Unavailable
/// - 其他错误:500 Internal Server Error
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
match error {
// 上游错误:使用实际状态码
ProxyError::UpstreamError { status, .. } => *status,
// 超时错误:504 Gateway Timeout
ProxyError::Timeout(_) => 504,
// 转发失败/连接失败:502 Bad Gateway
ProxyError::ForwardFailed(_) => 502,
// 无可用 Provider503 Service Unavailable
ProxyError::NoAvailableProvider => 503,
// 重试耗尽:503 Service Unavailable
ProxyError::MaxRetriesExceeded => 503,
// Provider 不健康:503 Service Unavailable
ProxyError::ProviderUnhealthy(_) => 503,
// 数据库错误:500 Internal Server Error
ProxyError::DatabaseError(_) => 500,
// 转换错误:500 Internal Server Error
ProxyError::TransformError(_) => 500,
// 其他未知错误:500 Internal Server Error
_ => 500,
}
}
/// 将 ProxyError 转换为用户友好的错误消息
pub fn get_error_message(error: &ProxyError) -> String {
match error {
ProxyError::UpstreamError { status, body } => {
if let Some(body) = body {
format!("上游错误 ({status}): {body}")
} else {
format!("上游错误 ({status})")
}
}
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"),
_ => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_upstream_error() {
let error = ProxyError::UpstreamError {
status: 401,
body: Some("Unauthorized".to_string()),
};
assert_eq!(map_proxy_error_to_status(&error), 401);
}
#[test]
fn test_map_timeout_error() {
let error = ProxyError::Timeout("Request timeout".to_string());
assert_eq!(map_proxy_error_to_status(&error), 504);
}
#[test]
fn test_map_connection_error() {
let error = ProxyError::ForwardFailed("Connection refused".to_string());
assert_eq!(map_proxy_error_to_status(&error), 502);
}
#[test]
fn test_map_no_provider_error() {
let error = ProxyError::NoAvailableProvider;
assert_eq!(map_proxy_error_to_status(&error), 503);
}
#[test]
fn test_get_error_message() {
let error = ProxyError::UpstreamError {
status: 500,
body: Some("Internal Server Error".to_string()),
};
let msg = get_error_message(&error);
assert!(msg.contains("上游错误"));
assert!(msg.contains("500"));
assert!(msg.contains("Internal Server Error"));
}
}
+5 -19
View File
@@ -48,17 +48,13 @@ impl FailoverSwitchManager {
provider_id: &str, provider_id: &str,
provider_name: &str, provider_name: &str,
) -> Result<bool, AppError> { ) -> Result<bool, AppError> {
let switch_key = format!("{}:{}", app_type, provider_id); let switch_key = format!("{app_type}:{provider_id}");
// 去重检查:如果相同切换已在进行中,跳过 // 去重检查:如果相同切换已在进行中,跳过
{ {
let mut pending = self.pending_switches.write().await; let mut pending = self.pending_switches.write().await;
if pending.contains(&switch_key) { if pending.contains(&switch_key) {
log::debug!( log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}");
"[Failover] 切换已在进行中,跳过: {} -> {}",
app_type,
provider_id
);
return Ok(false); return Ok(false);
} }
pending.insert(switch_key.clone()); pending.insert(switch_key.clone());
@@ -85,19 +81,14 @@ impl FailoverSwitchManager {
provider_id: &str, provider_id: &str,
provider_name: &str, provider_name: &str,
) -> Result<bool, AppError> { ) -> Result<bool, AppError> {
log::info!( log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
"[Failover] 开始切换供应商: {} -> {} ({})",
app_type,
provider_name,
provider_id
);
// 1. 更新数据库 is_current // 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?; self.db.set_current_provider(app_type, provider_id)?;
// 2. 更新本地 settings(设备级) // 2. 更新本地 settings(设备级)
let app_type_enum = crate::app_config::AppType::from_str(app_type) let app_type_enum = crate::app_config::AppType::from_str(app_type)
.map_err(|_| AppError::Message(format!("无效的应用类型: {}", app_type)))?; .map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?; crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
// 3. 更新托盘菜单和发射事件 // 3. 更新托盘菜单和发射事件
@@ -136,12 +127,7 @@ impl FailoverSwitchManager {
} }
} }
log::info!( log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
"[Failover] 供应商切换完成: {} -> {} ({})",
app_type,
provider_name,
provider_id
);
Ok(true) Ok(true)
} }
+53 -20
View File
@@ -29,9 +29,12 @@ pub struct RequestForwarder {
failover_manager: Arc<FailoverSwitchManager>, failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘 /// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
/// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
} }
impl RequestForwarder { impl RequestForwarder {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
router: Arc<ProviderRouter>, router: Arc<ProviderRouter>,
timeout_secs: u64, timeout_secs: u64,
@@ -40,6 +43,7 @@ impl RequestForwarder {
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>, current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>, failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>, app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
) -> Self { ) -> Self {
let mut client_builder = Client::builder(); let mut client_builder = Client::builder();
if timeout_secs > 0 { if timeout_secs > 0 {
@@ -58,6 +62,7 @@ impl RequestForwarder {
current_providers, current_providers,
failover_manager, failover_manager,
app_handle, app_handle,
current_provider_id_at_start,
} }
} }
@@ -94,10 +99,8 @@ impl RequestForwarder {
{ {
Ok(response) => return Ok(response), Ok(response) => return Ok(response),
Err(e) => { Err(e) => {
let category = self.categorize_proxy_error(&e); // 只有“同一 Provider 内可重试”的错误才继续重试
if !self.should_retry_same_provider(&e) {
// 只有可重试的错误才继续重试
if category == ErrorCategory::NonRetryable {
return Err(e); return Err(e);
} }
@@ -147,17 +150,16 @@ impl RequestForwarder {
); );
let mut last_error = None; let mut last_error = None;
let mut failover_happened = false;
let mut attempted_providers = 0usize; let mut attempted_providers = 0usize;
// 依次尝试每个供应商 // 依次尝试每个供应商
for provider in providers.iter() { for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
if !self let permit = self
.router .router
.allow_provider_request(&provider.id, app_type_str) .allow_provider_request(&provider.id, app_type_str)
.await .await;
{ if !permit.allowed {
log::debug!( log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过", "[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str, app_type_str,
@@ -166,10 +168,9 @@ impl RequestForwarder {
continue; continue;
} }
let used_half_open_permit = permit.used_half_open_permit;
attempted_providers += 1; attempted_providers += 1;
if attempted_providers > 1 {
failover_happened = true;
}
log::info!( log::info!(
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})", "[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
@@ -202,7 +203,13 @@ impl RequestForwarder {
// 成功:记录成功并更新熔断器 // 成功:记录成功并更新熔断器
if let Err(e) = self if let Err(e) = self
.router .router
.record_result(&provider.id, app_type_str, true, None) .record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await .await
{ {
log::warn!("Failed to record success: {e}"); log::warn!("Failed to record success: {e}");
@@ -222,16 +229,18 @@ impl RequestForwarder {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.success_requests += 1; status.success_requests += 1;
status.last_error = None; status.last_error = None;
if failover_happened { let should_switch =
self.current_provider_id_at_start.as_str() != provider.id.as_str();
if should_switch {
status.failover_count += 1; status.failover_count += 1;
log::info!( log::info!(
"[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)", "[{}] 代理目标已切换到 Provider: {} (耗时: {}ms)",
app_type_str, app_type_str,
provider.name, provider.name,
latency latency
); );
// 异步触发供应商切换,更新 UI 和托盘菜单 // 异步触发供应商切换,更新 UI/托盘,并把“当前供应商”同步为实际使用的 provider
let fm = self.failover_manager.clone(); let fm = self.failover_manager.clone();
let ah = self.app_handle.clone(); let ah = self.app_handle.clone();
let pid = provider.id.clone(); let pid = provider.id.clone();
@@ -268,7 +277,13 @@ impl RequestForwarder {
// 失败:记录失败并更新熔断器 // 失败:记录失败并更新熔断器
if let Err(record_err) = self if let Err(record_err) = self
.router .router
.record_result(&provider.id, app_type_str, false, Some(e.to_string())) .record_result(
&provider.id,
app_type_str,
used_half_open_permit,
false,
Some(e.to_string()),
)
.await .await
{ {
log::warn!("Failed to record failure: {record_err}"); log::warn!("Failed to record failure: {record_err}");
@@ -330,9 +345,8 @@ impl RequestForwarder {
status.failed_requests += 1; status.failed_requests += 1;
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string()); status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
if status.total_requests > 0 { if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32 status.success_rate =
/ status.total_requests as f32) (status.success_requests as f32 / status.total_requests as f32) * 100.0;
* 100.0;
} }
} }
return Err(ProxyError::NoAvailableProvider); return Err(ProxyError::NoAvailableProvider);
@@ -490,6 +504,19 @@ impl RequestForwarder {
/// ///
/// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。 /// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。
/// 只有明确是客户端中断的情况才不重试。 /// 只有明确是客户端中断的情况才不重试。
fn should_retry_same_provider(&self, error: &ProxyError) -> bool {
match error {
// 网络类错误:短暂抖动时同一 Provider 内重试有意义
ProxyError::Timeout(_) => true,
ProxyError::ForwardFailed(_) => true,
// 上游 HTTP 错误:只对“可能瞬态”的状态码做同 Provider 重试(其余交给 failover
ProxyError::UpstreamError { status, .. } => {
*status == 408 || *status == 429 || *status >= 500
}
_ => false,
}
}
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error { match error {
// 网络和上游错误:都应该尝试下一个供应商 // 网络和上游错误:都应该尝试下一个供应商
@@ -500,9 +527,15 @@ impl RequestForwarder {
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误 // 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
// 不代表其他供应商也会失败 // 不代表其他供应商也会失败
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable, ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,
// Provider 级配置/转换问题:换一个 Provider 可能就能成功
ProxyError::ConfigError(_) => ErrorCategory::Retryable,
ProxyError::TransformError(_) => ErrorCategory::Retryable,
ProxyError::AuthError(_) => ErrorCategory::Retryable,
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable,
// 无可用供应商:所有供应商都试过了,无法重试 // 无可用供应商:所有供应商都试过了,无法重试
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(配置错误、数据库错误等):不是供应商问题,无需重试 // 其他错误(数据库/内部错误等):不是供应商能解决的问题
_ => ErrorCategory::NonRetryable, _ => ErrorCategory::NonRetryable,
} }
} }
+9
View File
@@ -26,6 +26,11 @@ pub struct RequestContext {
pub provider: Provider, pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移) /// 完整的 Provider 列表(用于故障转移)
providers: Vec<Provider>, providers: Vec<Provider>,
/// 请求开始时的“当前供应商”(用于判断是否需要同步 UI/托盘)
///
/// 这里使用本地 settings 的设备级 current provider。
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
pub current_provider_id: String,
/// 请求中的模型名称 /// 请求中的模型名称
pub request_model: String, pub request_model: String,
/// 日志标签(如 "Claude"、"Codex"、"Gemini" /// 日志标签(如 "Claude"、"Codex"、"Gemini"
@@ -58,6 +63,8 @@ impl RequestContext {
) -> Result<Self, ProxyError> { ) -> Result<Self, ProxyError> {
let start_time = Instant::now(); let start_time = Instant::now();
let config = state.config.read().await.clone(); let config = state.config.read().await.clone();
let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default();
// 从请求体提取模型名称 // 从请求体提取模型名称
let request_model = body let request_model = body
@@ -92,6 +99,7 @@ impl RequestContext {
config, config,
provider, provider,
providers, providers,
current_provider_id,
request_model, request_model,
tag, tag,
app_type_str, app_type_str,
@@ -133,6 +141,7 @@ impl RequestContext {
state.current_providers.clone(), state.current_providers.clone(),
state.failover_manager.clone(), state.failover_manager.clone(),
state.app_handle.clone(), state.app_handle.clone(),
self.current_provider_id.clone(),
) )
} }
+83 -18
View File
@@ -5,17 +5,16 @@
//! 重构后的结构: //! 重构后的结构:
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块 //! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
//! - 各 handler 只保留独特的业务逻辑 //! - 各 handler 只保留独特的业务逻辑
//! - Claude 的格式转换逻辑保留在此文件(独有功能 //! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退
use super::{ use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{ handler_config::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
}, },
handler_context::RequestContext, handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform}, providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{ response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
create_logged_passthrough_stream, process_response, SseUsageCollector,
},
server::ProxyState, server::ProxyState,
types::*, types::*,
usage::parser::TokenUsage, usage::parser::TokenUsage,
@@ -55,15 +54,14 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// 处理 /v1/messages 请求(Claude API /// 处理 /v1/messages 请求(Claude API
/// ///
/// Claude 处理器包含独特的格式转换逻辑: /// Claude 处理器包含独特的格式转换逻辑:
/// - 当使用 OpenRouter 等中转服务时,需要将 Anthropic 格式转换为 OpenAI 格式 /// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic OpenAI 转换)
/// - 响应需要从 OpenAI 格式转回 Anthropic 格式 /// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages( pub async fn handle_messages(
State(state): State<ProxyState>, State(state): State<ProxyState>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let ctx = let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
// 检查是否需要格式转换(OpenRouter 等中转服务) // 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude); let adapter = get_adapter(&AppType::Claude);
@@ -83,7 +81,7 @@ pub async fn handle_messages(
// 转发请求 // 转发请求
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Claude, &AppType::Claude,
"/v1/messages", "/v1/messages",
@@ -91,7 +89,14 @@ pub async fn handle_messages(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
let status = response.status(); let status = response.status();
log::info!("[Claude] 上游响应状态: {status}"); log::info!("[Claude] 上游响应状态: {status}");
@@ -107,7 +112,7 @@ pub async fn handle_messages(
/// Claude 格式转换处理(独有逻辑) /// Claude 格式转换处理(独有逻辑)
/// ///
/// 处理 OpenRouter 等需要格式转换的中转服务 /// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
async fn handle_claude_transform( async fn handle_claude_transform(
response: reqwest::Response, response: reqwest::Response,
ctx: &RequestContext, ctx: &RequestContext,
@@ -304,7 +309,7 @@ pub async fn handle_chat_completions(
); );
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/chat/completions", "/v1/chat/completions",
@@ -312,7 +317,14 @@ pub async fn handle_chat_completions(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
@@ -327,8 +339,13 @@ pub async fn handle_responses(
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/responses", "/v1/responses",
@@ -336,7 +353,14 @@ pub async fn handle_responses(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
@@ -365,10 +389,15 @@ pub async fn handle_gemini(
.map(|pq| pq.as_str()) .map(|pq| pq.as_str())
.unwrap_or(uri.path()); .unwrap_or(uri.path());
log::info!("[Gemini] 请求端点: {}", endpoint); log::info!("[Gemini] 请求端点: {endpoint}");
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Gemini, &AppType::Gemini,
endpoint, endpoint,
@@ -376,7 +405,14 @@ pub async fn handle_gemini(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Gemini] 上游响应状态: {}", response.status()); log::info!("[Gemini] 上游响应状态: {}", response.status());
@@ -387,6 +423,35 @@ pub async fn handle_gemini(
// 使用量记录(保留用于 Claude 转换逻辑) // 使用量记录(保留用于 Claude 转换逻辑)
// ============================================================================ // ============================================================================
fn log_forward_error(
state: &ProxyState,
ctx: &RequestContext,
is_streaming: bool,
error: &ProxyError,
) {
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
let status_code = map_proxy_error_to_status(error);
let error_message = get_error_message(error);
let request_id = uuid::Uuid::new_v4().to_string();
if let Err(e) = logger.log_error_with_context(
request_id.clone(),
ctx.provider.id.clone(),
ctx.app_type_str.to_string(),
ctx.request_model.clone(),
status_code,
error_message,
ctx.latency_ms(),
is_streaming,
Some(request_id),
None,
) {
log::warn!("记录失败请求日志失败: {e}");
}
}
/// 记录请求使用量 /// 记录请求使用量
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn log_usage( async fn log_usage(
+1
View File
@@ -4,6 +4,7 @@
pub mod circuit_breaker; pub mod circuit_breaker;
pub mod error; pub mod error;
pub mod error_mapper;
pub(crate) mod failover_switch; pub(crate) mod failover_switch;
mod forwarder; mod forwarder;
pub mod handler_config; pub mod handler_config;
+10 -10
View File
@@ -5,7 +5,7 @@
use crate::database::Database; use crate::database::Database;
use crate::error::AppError; use crate::error::AppError;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig}; use crate::proxy::circuit_breaker::{AllowResult, CircuitBreaker, CircuitBreakerConfig};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -102,8 +102,7 @@ impl ProviderRouter {
if result.is_empty() { if result.is_empty() {
return Err(AppError::Config(format!( return Err(AppError::Config(format!(
"No available provider for {} (all circuit breakers open or no providers configured)", "No available provider for {app_type} (all circuit breakers open or no providers configured)"
app_type
))); )));
} }
@@ -124,7 +123,7 @@ impl ProviderRouter {
/// ///
/// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额, /// 注意:调用方必须在请求结束后通过 `record_result()` 释放 HalfOpen 名额,
/// 否则会导致该 Provider 长时间无法进入探测状态。 /// 否则会导致该 Provider 长时间无法进入探测状态。
pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> bool { pub async fn allow_provider_request(&self, provider_id: &str, app_type: &str) -> AllowResult {
let circuit_key = format!("{app_type}:{provider_id}"); let circuit_key = format!("{app_type}:{provider_id}");
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
breaker.allow_request().await breaker.allow_request().await
@@ -135,6 +134,7 @@ impl ProviderRouter {
&self, &self,
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
used_half_open_permit: bool,
success: bool, success: bool,
error_msg: Option<String>, error_msg: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
@@ -147,10 +147,10 @@ impl ProviderRouter {
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if success { if success {
breaker.record_success().await; breaker.record_success(used_half_open_permit).await;
log::debug!("Provider {provider_id} request succeeded"); log::debug!("Provider {provider_id} request succeeded");
} else { } else {
breaker.record_failure().await; breaker.record_failure(used_half_open_permit).await;
log::warn!( log::warn!(
"Provider {} request failed: {}", "Provider {} request failed: {}",
provider_id, provider_id,
@@ -199,7 +199,7 @@ impl ProviderRouter {
breaker.update_config(config.clone()).await; breaker.update_config(config.clone()).await;
} }
log::info!("已更新 {} 个熔断器的配置", count); log::info!("已更新 {count} 个熔断器的配置");
} }
/// 获取熔断器状态 /// 获取熔断器状态
@@ -266,7 +266,7 @@ mod tests {
// 测试创建熔断器 // 测试创建熔断器
let breaker = router.get_or_create_circuit_breaker("claude:test").await; let breaker = router.get_or_create_circuit_breaker("claude:test").await;
assert!(breaker.allow_request().await); assert!(breaker.allow_request().await.allowed);
} }
#[tokio::test] #[tokio::test]
@@ -297,7 +297,7 @@ mod tests {
// 让 B 进入 Open 状态(failure_threshold=1 // 让 B 进入 Open 状态(failure_threshold=1
router router
.record_result("b", "claude", false, Some("fail".to_string())) .record_result("b", "claude", false, false, Some("fail".to_string()))
.await .await
.unwrap(); .unwrap();
@@ -306,6 +306,6 @@ mod tests {
assert_eq!(providers.len(), 2); assert_eq!(providers.len(), 2);
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝) // 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
assert!(router.allow_provider_request("b", "claude").await); assert!(router.allow_provider_request("b", "claude").await.allowed);
} }
} }
+1 -1
View File
@@ -87,7 +87,7 @@ pub trait ProviderAdapter: Send + Sync {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// 默认返回 `false`(透传模式)。 /// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。 /// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
/// ///
/// # Arguments /// # Arguments
/// * `provider` - Provider 配置 /// * `provider` - Provider 配置
+51 -19
View File
@@ -5,7 +5,7 @@
//! ## 认证模式 //! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换 //! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider; use crate::provider::Provider;
@@ -28,11 +28,9 @@ impl ClaudeAdapter {
/// - Claude: 默认 Anthropic 官方 /// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType { pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 OpenRouter // 检测 OpenRouter
if let Ok(base_url) = self.extract_base_url(provider) { if self.is_openrouter(provider) {
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter; return ProviderType::OpenRouter;
} }
}
// 检测 ClaudeAuth (仅 Bearer 认证) // 检测 ClaudeAuth (仅 Bearer 认证)
if self.is_bearer_only_mode(provider) { if self.is_bearer_only_mode(provider) {
@@ -87,6 +85,14 @@ impl ClaudeAdapter {
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
return Some(key.to_string()); return Some(key.to_string());
} }
if let Some(key) = env
.get("ANTHROPIC_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
return Some(key.to_string());
}
// OpenRouter key // OpenRouter key
if let Some(key) = env if let Some(key) = env
.get("OPENROUTER_API_KEY") .get("OPENROUTER_API_KEY")
@@ -186,12 +192,17 @@ impl ProviderAdapter for ClaudeAdapter {
} }
fn build_url(&self, base_url: &str, endpoint: &str) -> String { fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// OpenRouter 使用 /v1/chat/completions // NOTE:
if base_url.contains("openrouter.ai") { // 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); // 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
} //
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可恢复下面这段分支:
//
// if base_url.contains("openrouter.ai") {
// return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
// }
// Anthropic 直连
format!( format!(
"{}/{}", "{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
@@ -207,19 +218,25 @@ impl ProviderAdapter for ClaudeAdapter {
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01"), .header("anthropic-version", "2023-06-01"),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key // ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => { AuthStrategy::ClaudeAuth => request
request.header("Authorization", format!("Bearer {}", auth.api_key)) .header("Authorization", format!("Bearer {}", auth.api_key))
} .header("anthropic-version", "2023-06-01"),
// OpenRouter: Bearer // OpenRouter: Bearer
AuthStrategy::Bearer => { AuthStrategy::Bearer => request
request.header("Authorization", format!("Bearer {}", auth.api_key)) .header("Authorization", format!("Bearer {}", auth.api_key))
} .header("anthropic-version", "2023-06-01"),
_ => request, _ => request,
} }
} }
fn needs_transform(&self, provider: &Provider) -> bool { fn needs_transform(&self, _provider: &Provider) -> bool {
self.is_openrouter(provider) // NOTE:
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
// Anthropic ↔ OpenAI 的格式转换。
//
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
// self.is_openrouter(_provider)
false
} }
fn transform_request( fn transform_request(
@@ -284,6 +301,21 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::Anthropic); assert_eq!(auth.strategy, AuthStrategy::Anthropic);
} }
#[test]
fn test_extract_auth_anthropic_api_key() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_API_KEY": "sk-ant-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test] #[test]
fn test_extract_auth_openrouter() { fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
@@ -378,7 +410,7 @@ mod tests {
fn test_build_url_openrouter() { fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new(); let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages"); let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions"); assert_eq!(url, "https://openrouter.ai/api/v1/messages");
} }
#[test] #[test]
@@ -397,6 +429,6 @@ mod tests {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api" "ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
} }
})); }));
assert!(adapter.needs_transform(&openrouter_provider)); assert!(!adapter.needs_transform(&openrouter_provider));
} }
} }
+8 -4
View File
@@ -48,17 +48,21 @@ pub enum ProviderType {
Gemini, Gemini,
/// Google Gemini CLI (OAuth Bearer) /// Google Gemini CLI (OAuth Bearer)
GeminiCli, GeminiCli,
/// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换) /// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
OpenRouter, OpenRouter,
} }
impl ProviderType { impl ProviderType {
/// 是否需要格式转换 /// 是否需要格式转换
/// ///
/// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式 /// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
#[allow(dead_code)] #[allow(dead_code)]
pub fn needs_transform(&self) -> bool { pub fn needs_transform(&self) -> bool {
matches!(self, ProviderType::OpenRouter) match self {
ProviderType::OpenRouter => false,
_ => false,
}
} }
/// 获取默认端点 /// 获取默认端点
@@ -215,7 +219,7 @@ mod tests {
assert!(!ProviderType::Codex.needs_transform()); assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform()); assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform()); assert!(!ProviderType::GeminiCli.needs_transform());
assert!(ProviderType::OpenRouter.needs_transform()); assert!(!ProviderType::OpenRouter.needs_transform());
} }
#[test] #[test]
+7
View File
@@ -191,16 +191,23 @@ impl ProxyServer {
.route("/v1/messages", post(handlers::handle_messages)) .route("/v1/messages", post(handlers::handle_messages))
.route("/claude/v1/messages", post(handlers::handle_messages)) .route("/claude/v1/messages", post(handlers::handle_messages))
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀) // OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
.route("/chat/completions", post(handlers::handle_chat_completions))
.route( .route(
"/v1/chat/completions", "/v1/chat/completions",
post(handlers::handle_chat_completions), post(handlers::handle_chat_completions),
) )
.route(
"/v1/v1/chat/completions",
post(handlers::handle_chat_completions),
)
.route( .route(
"/codex/v1/chat/completions", "/codex/v1/chat/completions",
post(handlers::handle_chat_completions), post(handlers::handle_chat_completions),
) )
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀) // OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
.route("/responses", post(handlers::handle_responses))
.route("/v1/responses", post(handlers::handle_responses)) .route("/v1/responses", post(handlers::handle_responses))
.route("/v1/v1/responses", post(handlers::handle_responses))
.route("/codex/v1/responses", post(handlers::handle_responses)) .route("/codex/v1/responses", post(handlers::handle_responses))
// Gemini API (支持带前缀和不带前缀) // Gemini API (支持带前缀和不带前缀)
.route("/v1beta/*path", post(handlers::handle_gemini)) .route("/v1beta/*path", post(handlers::handle_gemini))
+8 -3
View File
@@ -3,8 +3,6 @@ use serde::{Deserialize, Serialize};
/// 代理服务器配置 /// 代理服务器配置
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig { pub struct ProxyConfig {
/// 是否启用代理服务
pub enabled: bool,
/// 监听地址 /// 监听地址
pub listen_address: String, pub listen_address: String,
/// 监听端口 /// 监听端口
@@ -23,7 +21,6 @@ pub struct ProxyConfig {
impl Default for ProxyConfig { impl Default for ProxyConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false,
listen_address: "127.0.0.1".to_string(), listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口 listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3, max_retries: 3,
@@ -86,6 +83,14 @@ pub struct ProxyServerInfo {
pub started_at: String, pub started_at: String,
} }
/// 各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyTakeoverStatus {
pub claude: bool,
pub codex: bool,
pub gemini: bool,
}
/// API 格式类型(预留,当前不需要格式转换) /// API 格式类型(预留,当前不需要格式转换)
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] #[allow(dead_code)]
+39
View File
@@ -107,6 +107,8 @@ impl<'a> UsageLogger<'a> {
} }
/// 记录失败的请求 /// 记录失败的请求
///
/// 用于记录无法从上游获取 usage 信息的失败请求
#[allow(dead_code, clippy::too_many_arguments)] #[allow(dead_code, clippy::too_many_arguments)]
pub fn log_error( pub fn log_error(
&self, &self,
@@ -138,6 +140,43 @@ impl<'a> UsageLogger<'a> {
self.log_request(&log) self.log_request(&log)
} }
/// 记录失败的请求(带更多上下文信息)
///
/// 相比 log_error,这个方法接受更多参数以提供完整的请求上下文
#[allow(clippy::too_many_arguments)]
pub fn log_error_with_context(
&self,
request_id: String,
provider_id: String,
app_type: String,
model: String,
status_code: u16,
error_message: String,
latency_ms: u64,
is_streaming: bool,
session_id: Option<String>,
provider_type: Option<String>,
) -> Result<(), AppError> {
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
first_token_ms: None,
status_code,
error_message: Some(error_message),
session_id,
provider_type,
is_streaming,
cost_multiplier: "1.0".to_string(),
};
self.log_request(&log)
}
/// 获取模型定价 /// 获取模型定价
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> { pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
let conn = crate::database::lock_conn!(self.db.conn); let conn = crate::database::lock_conn!(self.db.conn);
+64 -9
View File
@@ -17,8 +17,27 @@ impl McpService {
/// 添加或更新 MCP 服务器 /// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> { pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
// 读取旧状态:用于处理“编辑时取消勾选某个应用”的场景(需要从对应 live 配置中移除)
let prev_apps = state
.db
.get_all_mcp_servers()?
.get(&server.id)
.map(|s| s.apps.clone())
.unwrap_or_default();
state.db.save_mcp_server(&server)?; state.db.save_mcp_server(&server)?;
// 处理禁用:若旧版本启用但新版本取消,则需要从该应用的 live 配置移除
if prev_apps.claude && !server.apps.claude {
Self::remove_server_from_app(state, &server.id, &AppType::Claude)?;
}
if prev_apps.codex && !server.apps.codex {
Self::remove_server_from_app(state, &server.id, &AppType::Codex)?;
}
if prev_apps.gemini && !server.apps.gemini {
Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?;
}
// 同步到各个启用的应用 // 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?; Self::sync_server_to_apps(state, &server)?;
@@ -190,10 +209,22 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
state.db.save_mcp_server(server)?; // 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致)
// 同步到 Claude live 配置 let to_save = if let Some(existing_server) = existing.get(&server.id) {
Self::sync_server_to_apps(state, server)?; let mut merged = existing_server.clone();
merged.apps.claude = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
} }
} }
} }
@@ -212,10 +243,22 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
state.db.save_mcp_server(server)?; // 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致)
// 同步到 Codex live 配置 let to_save = if let Some(existing_server) = existing.get(&server.id) {
Self::sync_server_to_apps(state, server)?; let mut merged = existing_server.clone();
merged.apps.codex = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
} }
} }
} }
@@ -234,10 +277,22 @@ impl McpService {
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
let mut existing = state.db.get_all_mcp_servers()?;
for server in servers.values() { for server in servers.values() {
state.db.save_mcp_server(server)?; // 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致)
// 同步到 Gemini live 配置 let to_save = if let Some(existing_server) = existing.get(&server.id) {
Self::sync_server_to_apps(state, server)?; let mut merged = existing_server.clone();
merged.apps.gemini = true;
merged
} else {
server.clone()
};
state.db.save_mcp_server(&to_save)?;
existing.insert(to_save.id.clone(), to_save.clone());
// 同步到对应应用 live 配置
Self::sync_server_to_apps(state, &to_save)?;
} }
} }
} }
+27 -11
View File
@@ -144,10 +144,30 @@ impl ProviderService {
state.db.save_provider(app_type.as_str(), &provider)?; state.db.save_provider(app_type.as_str(), &provider)?;
if is_current { if is_current {
// 如果代理接管模式处于激活状态,并且代理服务正在运行:
// - 不写 Live 配置(否则会破坏接管)
// - 仅更新 Live 备份(保证关闭代理时能恢复到最新配置)
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let should_skip_live_write = is_app_taken_over && is_proxy_running;
if should_skip_live_write {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), &provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
} else {
write_live_snapshot(&app_type, &provider)?; write_live_snapshot(&app_type, &provider)?;
// Sync MCP // Sync MCP
McpService::sync_all_enabled(state)?; McpService::sync_all_enabled(state)?;
} }
}
Ok(true) Ok(true)
} }
@@ -191,12 +211,15 @@ impl ProviderService {
// Check if proxy takeover mode is active AND proxy server is actually running // Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode // Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function // Use blocking wait since this is a sync function
let is_takeover_flag = let is_app_taken_over =
futures::executor::block_on(state.db.is_live_takeover_active()).unwrap_or(false); futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
// Hot-switch only when BOTH: takeover flag is set AND proxy server is actually running // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = is_takeover_flag && is_proxy_running; let should_hot_switch = is_app_taken_over && is_proxy_running;
if should_hot_switch { if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config // Proxy takeover mode: hot-switch only, don't write Live config
@@ -231,13 +254,6 @@ impl ProviderService {
} }
// Normal mode: full switch with Live config write // Normal mode: full switch with Live config write
// Also clear stale takeover flag if proxy is not running but flag was set
if is_takeover_flag && !is_proxy_running {
log::warn!("检测到代理接管标志残留(代理已停止),清除标志并执行正常切换");
// Clear stale takeover flag
let _ = futures::executor::block_on(state.db.set_live_takeover_active(false));
}
Self::switch_normal(state, app_type, id, &providers) Self::switch_normal(state, app_type, id, &providers)
} }
File diff suppressed because it is too large Load Diff
+32 -15
View File
@@ -31,40 +31,51 @@ impl SpeedtestService {
return Ok(vec![]); return Ok(vec![]);
} }
let timeout = Self::sanitize_timeout(timeout_secs); let mut results: Vec<Option<EndpointLatency>> = vec![None; urls.len()];
let client = Self::build_client(timeout)?; let mut valid_targets = Vec::new();
let tasks = urls.into_iter().map(|raw_url| { for (idx, raw_url) in urls.into_iter().enumerate() {
let client = client.clone();
async move {
let trimmed = raw_url.trim().to_string(); let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() { if trimmed.is_empty() {
return EndpointLatency { results[idx] = Some(EndpointLatency {
url: raw_url, url: raw_url,
latency: None, latency: None,
status: None, status: None,
error: Some("URL 不能为空".to_string()), error: Some("URL 不能为空".to_string()),
}; });
continue;
} }
let parsed_url = match Url::parse(&trimmed) { match Url::parse(&trimmed) {
Ok(url) => url, Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)),
Err(err) => { Err(err) => {
return EndpointLatency { results[idx] = Some(EndpointLatency {
url: trimmed, url: trimmed,
latency: None, latency: None,
status: None, status: None,
error: Some(format!("URL 无效: {err}")), error: Some(format!("URL 无效: {err}")),
}; });
}
}
} }
};
if valid_targets.is_empty() {
return Ok(results.into_iter().flatten().collect::<Vec<_>>());
}
let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?;
let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone();
async move {
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await; let _ = client.get(parsed_url.clone()).send().await;
// 第二次请求开始计时,并将其作为结果返回。 // 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now(); let start = Instant::now();
match client.get(parsed_url).send().await { let latency = match client.get(parsed_url).send().await {
Ok(resp) => EndpointLatency { Ok(resp) => EndpointLatency {
url: trimmed, url: trimmed,
latency: Some(start.elapsed().as_millis()), latency: Some(start.elapsed().as_millis()),
@@ -88,11 +99,17 @@ impl SpeedtestService {
error: Some(error_message), error: Some(error_message),
} }
} }
} };
(idx, latency)
} }
}); });
Ok(join_all(tasks).await) for (idx, latency) in join_all(tasks).await {
results[idx] = Some(latency);
}
Ok(results.into_iter().flatten().collect::<Vec<_>>())
} }
fn build_client(timeout_secs: u64) -> Result<Client, AppError> { fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch", "productName": "CC Switch",
"version": "3.8.2", "version": "3.9.0-2",
"identifier": "com.ccswitch.desktop", "identifier": "com.ccswitch.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+3 -1
View File
@@ -4,7 +4,9 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"titleBarStyle": "Visible" "titleBarStyle": "Visible",
"minWidth": 900,
"minHeight": 600
} }
] ]
} }
+84 -1
View File
@@ -154,6 +154,12 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
// 模拟 Codex 已安装/已初始化:存在 ~/.codex 目录
let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir");
}
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
config.mcp.codex.servers.insert( config.mcp.codex.servers.insert(
"stdio-enabled".into(), "stdio-enabled".into(),
@@ -170,7 +176,6 @@ fn sync_enabled_to_codex_writes_enabled_servers() {
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
let path = cc_switch_lib::get_codex_config_path();
assert!(path.exists(), "config.toml should be created"); assert!(path.exists(), "config.toml should be created");
let text = fs::read_to_string(&path).expect("read config.toml"); let text = fs::read_to_string(&path).expect("read config.toml");
assert!( assert!(
@@ -594,6 +599,11 @@ command = "echo"
fn sync_claude_enabled_mcp_projects_to_user_config() { fn sync_claude_enabled_mcp_projects_to_user_config() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create claude dir");
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
config.mcp.claude.servers.insert( config.mcp.claude.servers.insert(
@@ -993,3 +1003,76 @@ fn export_sql_returns_error_for_invalid_path() {
other => panic!("expected IoContext or Io error, got {other:?}"), other => panic!("expected IoContext or Io error, got {other:?}"),
} }
} }
#[test]
fn import_sql_rejects_non_cc_switch_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let state = create_test_state().expect("create test state");
let import_path = home.join("not-cc-switch.sql");
fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql");
let err = state
.db
.import_sql(&import_path)
.expect_err("non-cc-switch sql should be rejected");
match err {
AppError::Localized { key, .. } => {
assert_eq!(key, "backup.sql.invalid_format");
}
other => panic!("expected Localized error, got {other:?}"),
}
}
#[test]
fn import_sql_accepts_cc_switch_exported_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Create a database with some data and export it.
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "test-provider".to_string();
manager.providers.insert(
"test-provider".to_string(),
Provider::with_id(
"test-provider".to_string(),
"Test Provider".to_string(),
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
let export_path = home.join("cc-switch-export.sql");
state
.db
.export_sql(&export_path)
.expect("export should succeed");
// Reset database, then import into a fresh one.
reset_test_fs();
let state = create_test_state().expect("create test state");
state
.db
.import_sql(&export_path)
.expect("import should succeed");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("load providers");
assert!(
providers.contains_key("test-provider"),
"imported providers should contain test-provider"
);
}
+308
View File
@@ -246,3 +246,311 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
"codex config should include the enabled server definition" "codex config should include the enabled server definition"
); );
} }
#[test]
fn enabling_codex_mcp_skips_when_codex_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Codex 配置目录不存在(模拟“未安装/未运行过 Codex CLI”)
assert!(
!home.join(".codex").exists(),
"~/.codex should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Codex 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Codex:目录缺失时应跳过写入(不创建 ~/.codex/config.toml
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle codex should succeed even when ~/.codex is missing");
assert!(
!home.join(".codex").exists(),
"~/.codex should still not exist after skipped sync"
);
}
#[test]
fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 模拟 Claude 已安装/已初始化:存在 ~/.claude 目录
fs::create_dir_all(home.join(".claude")).expect("create ~/.claude dir");
// 先创建一个启用 Claude 的 MCP 服务器
let state = support::create_test_state().expect("create test state");
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert should sync to Claude live config");
// 确认已写入 ~/.claude.json
let mcp_path = get_claude_mcp_path();
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_some(),
"echo should exist in Claude live config after enabling"
);
// 再次 upsert:取消勾选 Claudeapps.claude=false),应从 Claude live 配置中移除
McpService::upsert_server(
&state,
McpServer {
id: "echo".to_string(),
name: "echo".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("upsert disabling app should remove from Claude live config");
let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json after disable");
let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json");
assert!(
v.pointer("/mcpServers/echo").is_none(),
"echo should be removed from Claude live config after disabling"
);
}
#[test]
fn import_mcp_from_multiple_apps_merges_enabled_flags() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 1) Claude: ~/.claude.json
let mcp_path = get_claude_mcp_path();
let claude_json = json!({
"mcpServers": {
"shared": {
"type": "stdio",
"command": "echo"
}
}
});
fs::write(
&mcp_path,
serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"),
)
.expect("seed ~/.claude.json");
// 2) Codex: ~/.codex/config.toml
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(
codex_dir.join("config.toml"),
r#"[mcp_servers.shared]
type = "stdio"
command = "echo"
"#,
)
.expect("seed ~/.codex/config.toml");
let state = support::create_test_state().expect("create test state");
McpService::import_from_claude(&state).expect("import from claude");
McpService::import_from_codex(&state).expect("import from codex");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("shared").expect("shared server exists");
assert!(entry.apps.claude, "shared should enable Claude");
assert!(entry.apps.codex, "shared should enable Codex");
}
#[test]
fn import_mcp_from_gemini_sse_url_only_is_valid() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// Gemini MCP 位于 ~/.gemini/settings.json
let gemini_dir = home.join(".gemini");
fs::create_dir_all(&gemini_dir).expect("create gemini dir");
let settings_path = gemini_dir.join("settings.json");
// Gemini SSE:只包含 urlGemini 不使用 type 字段)
let gemini_settings = json!({
"mcpServers": {
"sse-server": {
"url": "https://example.com/sse"
}
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"),
)
.expect("seed ~/.gemini/settings.json");
let state = support::create_test_state().expect("create test state");
let changed = McpService::import_from_gemini(&state).expect("import from gemini");
assert!(changed > 0, "should import at least 1 server");
let servers = state.db.get_all_mcp_servers().expect("get all mcp servers");
let entry = servers.get("sse-server").expect("sse-server exists");
assert!(entry.apps.gemini, "imported server should enable Gemini");
assert_eq!(
entry.server.get("type").and_then(|v| v.as_str()),
Some("sse"),
"Gemini url-only server should be normalized to type=sse in unified structure"
);
}
#[test]
fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Gemini 配置目录不存在(模拟“未安装/未运行过 Gemini CLI”)
assert!(
!home.join(".gemini").exists(),
"~/.gemini should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Gemini 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "gemini-server".to_string(),
name: "Gemini Server".to_string(),
server: json!({
"type": "sse",
"url": "https://example.com/sse"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Gemini:目录缺失时应跳过写入(不创建 ~/.gemini/settings.json
McpService::toggle_app(&state, "gemini-server", AppType::Gemini, true)
.expect("toggle gemini should succeed even when ~/.gemini is missing");
assert!(
!home.join(".gemini").exists(),
"~/.gemini should still not exist after skipped sync"
);
}
#[test]
fn enabling_claude_mcp_skips_when_claude_config_absent() {
use support::create_test_state;
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
// 确认 Claude 相关目录/文件都不存在(模拟“未安装/未运行过 Claude”)
assert!(
!home.join(".claude").exists(),
"~/.claude should not exist in fresh test environment"
);
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should not exist in fresh test environment"
);
let state = create_test_state().expect("create test state");
// 先插入一个未启用 Claude 的 MCP 服务器(避免 upsert 触发同步)
McpService::upsert_server(
&state,
McpServer {
id: "claude-server".to_string(),
name: "Claude Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
)
.expect("insert server without syncing");
// 启用 Claude:配置缺失时应跳过写入(不创建 ~/.claude.json
McpService::toggle_app(&state, "claude-server", AppType::Claude, true)
.expect("toggle claude should succeed even when ~/.claude is missing");
assert!(
!home.join(".claude.json").exists(),
"~/.claude.json should still not exist after skipped sync"
);
}
+30 -8
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useQueryClient } from "@tanstack/react-query";
import { import {
Plus, Plus,
Settings, Settings,
@@ -47,6 +48,7 @@ type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
function App() { function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppId>("claude"); const [activeApp, setActiveApp] = useState<AppId>("claude");
const [currentView, setCurrentView] = useState<View>("providers"); const [currentView, setCurrentView] = useState<View>("providers");
@@ -65,7 +67,9 @@ function App() {
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
// 获取代理服务状态 // 获取代理服务状态
const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus(); const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 获取供应商列表,当代理服务运行时自动刷新 // 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, { const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
@@ -268,7 +272,20 @@ function App() {
// 导入配置成功后刷新 // 导入配置成功后刷新
const handleImportSuccess = async () => { const handleImportSuccess = async () => {
try {
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
await queryClient.invalidateQueries({
queryKey: ["providers"],
refetchType: "all",
});
await queryClient.refetchQueries({
queryKey: ["providers"],
type: "all",
});
} catch (error) {
console.error("[App] Failed to refresh providers after import", error);
await refetch(); await refetch();
}
try { try {
await providersApi.updateTrayMenu(); await providersApi.updateTrayMenu();
} catch (error) { } catch (error) {
@@ -324,7 +341,7 @@ function App() {
appId={activeApp} appId={activeApp}
isLoading={isLoading} isLoading={isLoading}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isTakeoverActive} isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
onSwitch={switchProvider} onSwitch={switchProvider}
onEdit={setEditingProvider} onEdit={setEditingProvider}
onDelete={setConfirmDelete} onDelete={setConfirmDelete}
@@ -421,7 +438,7 @@ function App() {
rel="noreferrer" rel="noreferrer"
className={cn( className={cn(
"text-xl font-semibold transition-colors", "text-xl font-semibold transition-colors",
isProxyRunning && isTakeoverActive isProxyRunning && isCurrentAppTakeoverActive
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300" ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300", : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
)} )}
@@ -491,22 +508,26 @@ function App() {
)} )}
{currentView === "providers" && ( {currentView === "providers" && (
<> <>
<ProxyToggle /> <ProxyToggle activeApp={activeApp} />
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} /> <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="bg-muted p-1 rounded-xl flex items-center gap-1"> <div className="bg-muted p-1 rounded-xl flex items-center gap-1">
{hasSkillsSupport && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setCurrentView("skills")} onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
hasSkillsSupport
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")} title={t("skills.manage")}
> >
<Wrench className="h-4 w-4" /> <Wrench className="h-4 w-4 flex-shrink-0" />
</Button> </Button>
)}
{/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && ( {/* {isClaudeApp && (
<Button <Button
@@ -578,6 +599,7 @@ function App() {
}} }}
onSubmit={handleEditProvider} onSubmit={handleEditProvider}
appId={activeApp} appId={activeApp}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
/> />
{usageProvider && ( {usageProvider && (
+4 -4
View File
@@ -24,11 +24,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
}; };
return ( return (
<div className="inline-flex bg-muted rounded-lg p-1 gap-1"> <div className="inline-flex bg-muted rounded-xl p-1 gap-1">
<button <button
type="button" type="button"
onClick={() => handleSwitch("claude")} onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude" activeApp === "claude"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -50,7 +50,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("codex")} onClick={() => handleSwitch("codex")}
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex" activeApp === "codex"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -72,7 +72,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("gemini")} onClick={() => handleSwitch("gemini")}
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini" activeApp === "gemini"
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
@@ -16,6 +16,7 @@ interface EditProviderDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (provider: Provider) => Promise<void> | void; onSubmit: (provider: Provider) => Promise<void> | void;
appId: AppId; appId: AppId;
isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置)
} }
export function EditProviderDialog({ export function EditProviderDialog({
@@ -24,6 +25,7 @@ export function EditProviderDialog({
onOpenChange, onOpenChange,
onSubmit, onSubmit,
appId, appId,
isProxyTakeover = false,
}: EditProviderDialogProps) { }: EditProviderDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,6 +52,16 @@ export function EditProviderDialog({
return; return;
} }
// 代理接管模式:Live 配置已被代理改写,读取 live 会导致编辑界面展示代理地址/占位符等内容
// 因此直接回退到 SSOT(数据库)配置,避免用户困惑与误保存
if (isProxyTakeover) {
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
return;
}
try { try {
const currentId = await providersApi.getCurrent(appId); const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) { if (currentId && provider.id === currentId) {
@@ -82,7 +94,7 @@ export function EditProviderDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象 }, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => { const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record< return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import type { ProviderCategory } from "@/types"; import type { ProviderCategory } from "@/types";
import { import {
getApiKeyFromConfig, getApiKeyFromConfig,
@@ -32,6 +32,28 @@ export function useApiKeyState({
return ""; return "";
}); });
// 当外部通过 form.reset / 读取 live 等方式更新配置时,同步回 API Key 状态
// - 仅在 JSON 可解析时同步,避免用户编辑 JSON 过程中因临时无效导致输入框闪烁
useEffect(() => {
if (!initialConfig) return;
try {
JSON.parse(initialConfig);
} catch {
return;
}
// 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key)
if (!hasApiKeyField(initialConfig, appType)) {
return;
}
const extracted = getApiKeyFromConfig(initialConfig, appType);
if (extracted !== apiKey) {
setApiKey(extracted);
}
}, [initialConfig, appType, apiKey]);
const handleApiKeyChange = useCallback( const handleApiKeyChange = useCallback(
(key: string) => { (key: string) => {
setApiKey(key); setApiKey(key);
+30 -3
View File
@@ -1,5 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Activity, Clock, TrendingUp, Server, ListOrdered } from "lucide-react"; import {
Activity,
Clock,
TrendingUp,
Server,
ListOrdered,
Settings,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProxySettingsDialog } from "./ProxySettingsDialog"; import { ProxySettingsDialog } from "./ProxySettingsDialog";
@@ -40,8 +47,19 @@ export function ProxyPanel() {
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4"> <div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div> <div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center"> <Button
size="sm"
variant="ghost"
onClick={() => setShowSettings(true)}
className="h-7 gap-1.5 text-xs"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60"> <code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port} http://{status.address}:{status.port}
</code> </code>
@@ -190,9 +208,18 @@ export function ProxyPanel() {
<p className="text-base font-medium text-foreground mb-1"> <p className="text-base font-medium text-foreground mb-1">
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground mb-4">
使 使
</p> </p>
<Button
size="sm"
variant="outline"
onClick={() => setShowSettings(true)}
className="gap-1.5"
>
<Settings className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</section> </section>
+11 -9
View File
@@ -26,8 +26,11 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy"; import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(包含 enabled 字段,该字段由后端自动管理 // 表单数据类型(包含可编辑字段
type ProxyConfigForm = Omit<ProxyConfig, "enabled">; type ProxyConfigForm = Pick<
ProxyConfig,
"listen_address" | "listen_port" | "max_retries" | "request_timeout" | "enable_logging"
>;
const createProxyConfigSchema = (t: TFunction) => { const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z const requestTimeoutSchema = z
@@ -120,19 +123,18 @@ export function ProxySettingsDialog({
useEffect(() => { useEffect(() => {
if (config) { if (config) {
form.reset({ form.reset({
...config, listen_address: config.listen_address,
listen_port: config.listen_port,
max_retries: config.max_retries,
request_timeout: config.request_timeout,
enable_logging: config.enable_logging,
}); });
} }
}, [config, form]); }, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => { const onSubmit = async (data: ProxyConfigForm) => {
try { try {
// 添加 enabled 字段(从当前配置中获取,保持不变) await updateConfig(data);
const configToSave: ProxyConfig = {
...data,
enabled: config?.enabled ?? true,
};
await updateConfig(configToSave);
closePanel(); closePanel();
} catch (error) { } catch (error) {
console.error("Save config failed:", error); console.error("Save config failed:", error);
+33 -25
View File
@@ -9,53 +9,60 @@ import { Radio, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { AppId } from "@/lib/api";
interface ProxyToggleProps { interface ProxyToggleProps {
className?: string; className?: string;
activeApp: AppId;
} }
export function ProxyToggle({ className }: ProxyToggleProps) { export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
const { const { t } = useTranslation();
isRunning, const { isRunning, takeoverStatus, setTakeoverForApp, isPending, status } =
isTakeoverActive, useProxyStatus();
startWithTakeover,
stopWithRestore,
isPending,
status,
} = useProxyStatus();
const handleToggle = async (checked: boolean) => { const handleToggle = async (checked: boolean) => {
if (checked) { await setTakeoverForApp({ appType: activeApp, enabled: checked });
await startWithTakeover();
} else {
await stopWithRestore();
}
}; };
const isActive = isRunning && isTakeoverActive; const takeoverEnabled = takeoverStatus?.[activeApp] || false;
const tooltipText = isActive const appLabel =
? `代理模式运行中 - ${status?.address}:${status?.port}\n切换供应商为热切换` activeApp === "claude"
: "开启代理模式\n启用后自动接管 Live 配置"; ? "Claude"
: activeApp === "codex"
? "Codex"
: "Gemini";
const tooltipText = takeoverEnabled
? isRunning
? t("proxy.takeover.tooltip.active", {
defaultValue: `${appLabel} 已接管 - ${status?.address}:${status?.port}\n切换该应用供应商为热切换`,
})
: t("proxy.takeover.tooltip.broken", {
defaultValue: `${appLabel} 已接管,但代理服务未运行`,
})
: t("proxy.takeover.tooltip.inactive", {
defaultValue: `接管 ${appLabel} 的 Live 配置,让该应用请求走本地代理`,
});
return ( return (
<div <div
className={cn( className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default", "p-1 rounded-xl transition-all",
isActive
? "bg-emerald-500/10 border border-emerald-500/30"
: "bg-muted/50 hover:bg-muted",
className, className,
)} )}
title={tooltipText} title={tooltipText}
> >
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : ( ) : (
<Radio <Radio
className={cn( className={cn(
"h-4 w-4 transition-colors", "h-4 w-4 transition-colors",
isActive takeoverEnabled
? "text-emerald-500 animate-pulse" ? "text-emerald-500 animate-pulse"
: "text-muted-foreground", : "text-muted-foreground",
)} )}
@@ -64,7 +71,7 @@ export function ProxyToggle({ className }: ProxyToggleProps) {
<span <span
className={cn( className={cn(
"text-sm font-medium transition-colors select-none", "text-sm font-medium transition-colors select-none",
isActive takeoverEnabled
? "text-emerald-600 dark:text-emerald-400" ? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground", : "text-muted-foreground",
)} )}
@@ -72,11 +79,12 @@ export function ProxyToggle({ className }: ProxyToggleProps) {
Proxy Proxy
</span> </span>
<Switch <Switch
checked={isActive} checked={takeoverEnabled}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
disabled={isPending} disabled={isPending}
className="ml-1" className="ml-1"
/> />
</div> </div>
</div>
); );
} }
+147 -45
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Download, Download,
Copy,
ExternalLink, ExternalLink,
Info, Info,
Loader2, Loader2,
@@ -8,6 +9,7 @@ import {
Terminal, Terminal,
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -17,6 +19,7 @@ import { settingsApi } from "@/lib/api";
import { useUpdate } from "@/contexts/UpdateContext"; import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater"; import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
interface AboutSectionProps { interface AboutSectionProps {
isPortable: boolean; isPortable: boolean;
@@ -29,6 +32,10 @@ interface ToolVersion {
error: string | null; error: string | null;
} }
const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest
npm i -g @openai/codex@latest
npm i -g @google/gemini-cli@latest`;
export function AboutSection({ isPortable }: AboutSectionProps) { export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ... // ... (use hooks as before) ...
const { t } = useTranslation(); const { t } = useTranslation();
@@ -47,6 +54,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
isChecking, isChecking,
} = useUpdate(); } = useUpdate();
const loadToolVersions = useCallback(async () => {
setIsLoadingTools(true);
try {
const tools = await settingsApi.getToolVersions();
setToolVersions(tools);
} catch (error) {
console.error("[AboutSection] Failed to load tool versions", error);
} finally {
setIsLoadingTools(false);
}
}, []);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
const load = async () => { const load = async () => {
@@ -150,10 +169,25 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
} }
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]); }, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
const handleCopyInstallCommands = useCallback(async () => {
try {
await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS);
toast.success(t("settings.installCommandsCopied"), { closeButton: true });
} catch (error) {
console.error("[AboutSection] Failed to copy install commands", error);
toast.error(t("settings.installCommandsCopyFailed"));
}
}, [t]);
const displayVersion = version ?? t("common.unknown"); const displayVersion = version ?? t("common.unknown");
return ( return (
<section className="space-y-6"> <motion.section
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<header className="space-y-1"> <header className="space-y-1">
<h3 className="text-sm font-medium">{t("common.about")}</h3> <h3 className="text-sm font-medium">{t("common.about")}</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -161,12 +195,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</p> </p>
</header> </header>
<div className="rounded-xl border border-border bg-card/50 p-6 space-y-6"> <motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background"> <Sparkles className="h-5 w-5 text-primary" />
<h4 className="text-lg font-semibold text-foreground">
CC Switch
</h4>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background/80">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("common.version")} {t("common.version")}
</span> </span>
@@ -185,15 +229,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleOpenReleaseNotes} onClick={handleOpenReleaseNotes}
className="h-9" className="h-8 gap-1.5 text-xs"
> >
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="h-3.5 w-3.5" />
{t("settings.releaseNotes")} {t("settings.releaseNotes")}
</Button> </Button>
<Button <Button
@@ -201,34 +245,41 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm" size="sm"
onClick={handleCheckUpdate} onClick={handleCheckUpdate}
disabled={isChecking || isDownloading} disabled={isChecking || isDownloading}
className="min-w-[140px] h-9" className="h-8 gap-1.5 text-xs"
> >
{isDownloading ? ( {isDownloading ? (
<span className="inline-flex items-center gap-2"> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("settings.updating")} {t("settings.updating")}
</span> </>
) : hasUpdate ? ( ) : hasUpdate ? (
<span className="inline-flex items-center gap-2"> <>
<Download className="h-4 w-4" /> <Download className="h-3.5 w-3.5" />
{t("settings.updateTo", { {t("settings.updateTo", {
version: updateInfo?.availableVersion ?? "", version: updateInfo?.availableVersion ?? "",
})} })}
</span> </>
) : isChecking ? ( ) : isChecking ? (
<span className="inline-flex items-center gap-2"> <>
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-3.5 w-3.5 animate-spin" />
{t("settings.checking")} {t("settings.checking")}
</span> </>
) : ( ) : (
t("settings.checkForUpdates") <>
<RefreshCw className="h-3.5 w-3.5" />
{t("settings.checkForUpdates")}
</>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
{hasUpdate && updateInfo && ( {hasUpdate && updateInfo && (
<div className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm"> <motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm"
>
<p className="font-medium text-primary mb-1"> <p className="font-medium text-primary mb-1">
{t("settings.updateAvailable", { {t("settings.updateAvailable", {
version: updateInfo.availableVersion, version: updateInfo.availableVersion,
@@ -239,40 +290,58 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{updateInfo.notes} {updateInfo.notes}
</p> </p>
)} )}
</div> </motion.div>
)} )}
</div> </motion.div>
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground px-1"> <div className="flex items-center justify-between px-1">
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
</h4> <Button
<div className="grid gap-3 sm:grid-cols-3"> size="sm"
{isLoadingTools variant="outline"
? Array.from({ length: 3 }).map((_, i) => ( className="h-7 gap-1.5 text-xs"
<div onClick={loadToolVersions}
key={i} disabled={isLoadingTools}
className="h-20 rounded-xl border border-border bg-card/50 animate-pulse" >
<RefreshCw
className={
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
}
/> />
)) {isLoadingTools ? t("common.refreshing") : t("common.refresh")}
: toolVersions.map((tool) => ( </Button>
<div </div>
key={tool.name} <div className="grid gap-3 sm:grid-cols-3">
className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50" {["claude", "codex", "gemini"].map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
const displayName = tool?.name ?? toolName;
const title = tool?.version || tool?.error || t("common.unknown");
return (
<motion.div
key={toolName}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}
whileHover={{ scale: 1.02 }}
className="flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" /> <Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize"> <span className="text-sm font-medium capitalize">
{tool.name} {displayName}
</span> </span>
</div> </div>
{tool.version ? ( {isLoadingTools ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : tool?.version ? (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{tool.latest_version && {tool.latest_version &&
tool.version !== tool.latest_version && ( tool.version !== tool.latest_version && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20"> <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
Update: {tool.latest_version} {tool.latest_version}
</span> </span>
)} )}
<CheckCircle2 className="h-4 w-4 text-green-500" /> <CheckCircle2 className="h-4 w-4 text-green-500" />
@@ -281,18 +350,51 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<AlertCircle className="h-4 w-4 text-yellow-500" /> <AlertCircle className="h-4 w-4 text-yellow-500" />
)} )}
</div> </div>
<div className="flex flex-col gap-0.5">
<div <div
className="text-xs font-mono truncate" className="text-xs font-mono text-muted-foreground truncate"
title={tool.version || tool.error || "Unknown"} title={title}
> >
{tool.version ? tool.version : tool.error || "未安装"} {isLoadingTools
? t("common.loading")
: tool?.version
? tool.version
: tool?.error || t("common.notInstalled")}
</div>
</motion.div>
);
})}
</div> </div>
</div> </div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="space-y-3"
>
<h3 className="text-sm font-medium px-1">
{t("settings.oneClickInstall")}
</h3>
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{t("settings.oneClickInstallHint")}
</p>
<Button
size="sm"
variant="outline"
onClick={handleCopyInstallCommands}
className="h-7 gap-1.5 text-xs"
>
<Copy className="h-3.5 w-3.5" />
{t("common.copy")}
</Button>
</div> </div>
))} <pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
{ONE_CLICK_INSTALL_COMMANDS}
</pre>
</div> </div>
</div> </motion.div>
</section> </motion.section>
); );
} }
+4 -4
View File
@@ -177,8 +177,8 @@ export function SettingsPage({
const { const {
isRunning, isRunning,
startWithTakeover: startProxy, startProxyServer,
stopWithRestore: stopProxy, stopWithRestore,
isPending: isProxyPending, isPending: isProxyPending,
} = useProxyStatus(); } = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true); const [failoverEnabled, setFailoverEnabled] = useState(true);
@@ -186,9 +186,9 @@ export function SettingsPage({
const handleToggleProxy = async (checked: boolean) => { const handleToggleProxy = async (checked: boolean) => {
try { try {
if (!checked) { if (!checked) {
await stopProxy(); await stopWithRestore();
} else { } else {
await startProxy(); await startProxyServer();
} }
} catch (error) { } catch (error) {
console.error("Toggle proxy failed:", error); console.error("Toggle proxy failed:", error);
+9 -1
View File
@@ -185,6 +185,8 @@ export const providerPresets: ProviderPreset[] = [
}, },
}, },
category: "aggregator", category: "aggregator",
icon: "modelscope",
iconColor: "#624AFF",
}, },
{ {
name: "KAT-Coder", name: "KAT-Coder",
@@ -228,6 +230,8 @@ export const providerPresets: ProviderPreset[] = [
}, },
}, },
category: "cn_official", category: "cn_official",
icon: "longcat",
iconColor: "#29E154",
}, },
{ {
name: "MiniMax", name: "MiniMax",
@@ -330,6 +334,8 @@ export const providerPresets: ProviderPreset[] = [
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖 // 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"], endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
category: "aggregator", category: "aggregator",
icon: "aihubmix",
iconColor: "#006FFB",
}, },
{ {
name: "DMXAPI", name: "DMXAPI",
@@ -344,6 +350,8 @@ export const providerPresets: ProviderPreset[] = [
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖 // 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"], endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
category: "aggregator", category: "aggregator",
isPartner: true, // 合作伙伴
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
}, },
{ {
name: "PackyCode", name: "PackyCode",
@@ -381,6 +389,6 @@ export const providerPresets: ProviderPreset[] = [
}, },
category: "aggregator", category: "aggregator",
icon: "openrouter", icon: "openrouter",
iconColor: "#6366F1", iconColor: "#6566F1",
}, },
]; ];
+3 -1
View File
@@ -80,7 +80,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
{ {
name: "Azure OpenAI", name: "Azure OpenAI",
websiteUrl: websiteUrl:
"https://learn.microsoft.com/azure/ai-services/openai/how-to/overview", "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/codex",
category: "third_party", category: "third_party",
isOfficial: true, isOfficial: true,
auth: generateThirdPartyAuth(""), auth: generateThirdPartyAuth(""),
@@ -131,6 +131,8 @@ requires_openai_auth = true`,
"gpt-5.1-codex", "gpt-5.1-codex",
), ),
endpointCandidates: ["https://www.dmxapi.cn/v1"], endpointCandidates: ["https://www.dmxapi.cn/v1"],
isPartner: true, // 合作伙伴
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
}, },
{ {
name: "PackyCode", name: "PackyCode",
+5 -14
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
@@ -39,15 +39,6 @@ export function useImportExport(
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [backupId, setBackupId] = useState<string | null>(null); const [backupId, setBackupId] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const successTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) {
window.clearTimeout(successTimerRef.current);
}
};
}, []);
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setSelectedFile(""); setSelectedFile("");
@@ -105,6 +96,10 @@ export function useImportExport(
} }
setBackupId(result.backupId ?? null); setBackupId(result.backupId ?? null);
// 导入成功后立即触发外部刷新(与 live 同步结果解耦)
// - 避免 sync 失败时 UI 不刷新
// - 避免依赖 setTimeout(组件卸载会取消)
void onImportSuccess?.();
const syncResult = await syncCurrentProvidersLiveSafe(); const syncResult = await syncCurrentProvidersLiveSafe();
if (syncResult.ok) { if (syncResult.ok) {
@@ -115,10 +110,6 @@ export function useImportExport(
}), }),
{ closeButton: true }, { closeButton: true },
); );
successTimerRef.current = window.setTimeout(() => {
void onImportSuccess?.();
}, 1500);
} else { } else {
console.error( console.error(
"[useImportExport] Failed to sync live config", "[useImportExport] Failed to sync live config",
+71 -21
View File
@@ -6,7 +6,11 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ProxyStatus, ProxyServerInfo } from "@/types/proxy"; import type {
ProxyStatus,
ProxyServerInfo,
ProxyTakeoverStatus,
} from "@/types/proxy";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
/** /**
@@ -26,47 +30,47 @@ export function useProxyStatus() {
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
// 查询接管状态 // 查询各应用接管状态
const { data: isTakeoverActive } = useQuery({ const { data: takeoverStatus } = useQuery({
queryKey: ["proxyTakeoverActive"], queryKey: ["proxyTakeoverStatus"],
queryFn: () => invoke<boolean>("is_live_takeover_active"), queryFn: () => invoke<ProxyTakeoverStatus>("get_proxy_takeover_status"),
placeholderData: (previousData) => previousData,
}); });
// 启动服务器(带 Live 配置接管) // 启动服务器(总开关:仅启动服务,不接管)
const startWithTakeoverMutation = useMutation({ const startProxyServerMutation = useMutation({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_with_takeover"), mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"),
onSuccess: (info) => { onSuccess: (info) => {
toast.success( toast.success(
t("proxy.startedWithTakeover", { t("proxy.server.started", {
defaultValue: `代理模式已启 - ${info.address}:${info.port}`, defaultValue: `代理服务已启 - ${info.address}:${info.port}`,
}), }),
{ closeButton: true }, { closeButton: true },
); );
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] });
}, },
onError: (error: Error) => { onError: (error: Error) => {
const detail = extractErrorMessage(error) || "未知错误"; const detail = extractErrorMessage(error) || "未知错误";
toast.error( toast.error(
t("proxy.startWithTakeoverFailed", { t("proxy.server.startFailed", {
defaultValue: `启动失败: ${detail}`, defaultValue: `启动代理服务失败: ${detail}`,
}), }),
); );
}, },
}); });
// 停止服务器(恢复 Live 配置) // 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置)
const stopWithRestoreMutation = useMutation({ const stopWithRestoreMutation = useMutation({
mutationFn: () => invoke("stop_proxy_with_restore"), mutationFn: () => invoke("stop_proxy_with_restore"),
onSuccess: () => { onSuccess: () => {
toast.success( toast.success(
t("proxy.stoppedWithRestore", { t("proxy.stoppedWithRestore", {
defaultValue: "代理模式已关闭,配置已恢复", defaultValue: "代理服务已关闭,已恢复所有接管配置",
}), }),
{ closeButton: true }, { closeButton: true },
); );
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverActive"] }); queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
// 清除所有供应商健康状态缓存(后端已清空数据库记录) // 清除所有供应商健康状态缓存(后端已清空数据库记录)
queryClient.invalidateQueries({ queryKey: ["providerHealth"] }); queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
}, },
@@ -80,6 +84,42 @@ export function useProxyStatus() {
}, },
}); });
// 按应用开启/关闭接管
const setTakeoverForAppMutation = useMutation({
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
invoke("set_proxy_takeover_for_app", { appType, enabled }),
onSuccess: (_data, variables) => {
const appLabel =
variables.appType === "claude"
? "Claude"
: variables.appType === "codex"
? "Codex"
: "Gemini";
toast.success(
variables.enabled
? t("proxy.takeover.enabled", {
defaultValue: `已接管 ${appLabel} 配置(请求将走本地代理)`,
})
: t("proxy.takeover.disabled", {
defaultValue: `已恢复 ${appLabel} 配置`,
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
},
onError: (error: Error) => {
const detail = extractErrorMessage(error) || "未知错误";
toast.error(
t("proxy.takeover.failed", {
defaultValue: `操作失败: ${detail}`,
}),
);
},
});
// 代理模式切换供应商(热切换) // 代理模式切换供应商(热切换)
const switchProxyProviderMutation = useMutation({ const switchProxyProviderMutation = useMutation({
mutationFn: ({ mutationFn: ({
@@ -120,12 +160,20 @@ export function useProxyStatus() {
status, status,
isLoading, isLoading,
isRunning: status?.running || false, isRunning: status?.running || false,
isTakeoverActive: isTakeoverActive || false, takeoverStatus,
isTakeoverActive:
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
false,
// 启动/停止(接管模式 // 启动/停止(总开关
startWithTakeover: startWithTakeoverMutation.mutateAsync, startProxyServer: startProxyServerMutation.mutateAsync,
stopWithRestore: stopWithRestoreMutation.mutateAsync, stopWithRestore: stopWithRestoreMutation.mutateAsync,
// 按应用接管开关
setTakeoverForApp: setTakeoverForAppMutation.mutateAsync,
// 代理模式下切换供应商 // 代理模式下切换供应商
switchProxyProvider: switchProxyProviderMutation.mutateAsync, switchProxyProvider: switchProxyProviderMutation.mutateAsync,
@@ -134,9 +182,11 @@ export function useProxyStatus() {
checkTakeoverActive, checkTakeoverActive,
// 加载状态 // 加载状态
isStarting: startWithTakeoverMutation.isPending, isStarting: startProxyServerMutation.isPending,
isStopping: stopWithRestoreMutation.isPending, isStopping: stopWithRestoreMutation.isPending,
isPending: isPending:
startWithTakeoverMutation.isPending || stopWithRestoreMutation.isPending, startProxyServerMutation.isPending ||
stopWithRestoreMutation.isPending ||
setTakeoverForAppMutation.isPending,
}; };
} }
+14 -4
View File
@@ -17,6 +17,7 @@
"about": "About", "about": "About",
"version": "Version", "version": "Version",
"loading": "Loading...", "loading": "Loading...",
"notInstalled": "Not installed",
"success": "Success", "success": "Success",
"error": "Error", "error": "Error",
"unknown": "Unknown", "unknown": "Unknown",
@@ -28,7 +29,10 @@
"formatError": "Format failed: {{error}}", "formatError": "Format failed: {{error}}",
"copy": "Copy", "copy": "Copy",
"view": "View", "view": "View",
"back": "Back" "back": "Back",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"notInstalled": "Not installed"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "Enter API Key", "placeholder": "Enter API Key",
@@ -146,7 +150,7 @@
"themeDark": "Dark", "themeDark": "Dark",
"themeSystem": "System", "themeSystem": "System",
"importExport": "SQL Import/Export", "importExport": "SQL Import/Export",
"importExportHint": "Import or export database SQL backups for migration or restore.", "importExportHint": "Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).",
"exportConfig": "Export SQL Backup", "exportConfig": "Export SQL Backup",
"selectConfigFile": "Select SQL File", "selectConfigFile": "Select SQL File",
"noFileSelected": "No configuration file selected.", "noFileSelected": "No configuration file selected.",
@@ -162,7 +166,7 @@
"selectFileFailed": "Please choose a valid SQL backup file", "selectFileFailed": "Please choose a valid SQL backup file",
"configCorrupted": "SQL file may be corrupted or invalid", "configCorrupted": "SQL file may be corrupted or invalid",
"backupId": "Backup ID", "backupId": "Backup ID",
"autoReload": "Data will refresh automatically in 2 seconds...", "autoReload": "Data refreshed",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語", "languageOptionJapanese": "日本語",
@@ -205,6 +209,11 @@
"releaseNotes": "Release Notes", "releaseNotes": "Release Notes",
"viewReleaseNotes": "View release notes for this version", "viewReleaseNotes": "View release notes for this version",
"viewCurrentReleaseNotes": "View current version release notes", "viewCurrentReleaseNotes": "View current version release notes",
"oneClickInstall": "One-click Install",
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI",
"localEnvCheck": "Local environment check",
"installCommandsCopied": "Install commands copied",
"installCommandsCopyFailed": "Copy failed, please copy manually.",
"importFailedError": "Import config failed: {{message}}", "importFailedError": "Import config failed: {{message}}",
"exportFailedError": "Export config failed:", "exportFailedError": "Export config failed:",
"restartRequired": "Restart Required", "restartRequired": "Restart Required",
@@ -264,7 +273,8 @@
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount", "zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off", "packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9", "minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)" "minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
"dmxapi": "Claude Code exclusive model 66% OFF now!"
}, },
"parameterConfig": "Parameter Config - {{name}} *", "parameterConfig": "Parameter Config - {{name}} *",
"mainModel": "Main Model (optional)", "mainModel": "Main Model (optional)",
+14 -4
View File
@@ -17,6 +17,7 @@
"about": "バージョン情報", "about": "バージョン情報",
"version": "バージョン", "version": "バージョン",
"loading": "読み込み中...", "loading": "読み込み中...",
"notInstalled": "未インストール",
"success": "成功", "success": "成功",
"error": "エラー", "error": "エラー",
"unknown": "不明", "unknown": "不明",
@@ -28,7 +29,10 @@
"formatError": "整形に失敗しました: {{error}}", "formatError": "整形に失敗しました: {{error}}",
"copy": "コピー", "copy": "コピー",
"view": "表示", "view": "表示",
"back": "戻る" "back": "戻る",
"refresh": "更新",
"refreshing": "更新中...",
"notInstalled": "未インストール"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "API Key を入力", "placeholder": "API Key を入力",
@@ -146,7 +150,7 @@
"themeDark": "ダーク", "themeDark": "ダーク",
"themeSystem": "システム", "themeSystem": "システム",
"importExport": "SQL インポート/エクスポート", "importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。", "importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。",
"exportConfig": "SQL バックアップをエクスポート", "exportConfig": "SQL バックアップをエクスポート",
"selectConfigFile": "SQL ファイルを選択", "selectConfigFile": "SQL ファイルを選択",
"noFileSelected": "ファイルが選択されていません。", "noFileSelected": "ファイルが選択されていません。",
@@ -162,7 +166,7 @@
"selectFileFailed": "有効な SQL バックアップファイルを選択してください", "selectFileFailed": "有効な SQL バックアップファイルを選択してください",
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります", "configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
"backupId": "バックアップ ID", "backupId": "バックアップ ID",
"autoReload": "2 秒後に自動で再読み込みします...", "autoReload": "データを更新しました",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語", "languageOptionJapanese": "日本語",
@@ -205,6 +209,11 @@
"releaseNotes": "リリースノート", "releaseNotes": "リリースノート",
"viewReleaseNotes": "このバージョンのリリースノートを見る", "viewReleaseNotes": "このバージョンのリリースノートを見る",
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る", "viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
"oneClickInstall": "ワンクリックインストール",
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール",
"localEnvCheck": "ローカル環境チェック",
"installCommandsCopied": "インストールコマンドをコピーしました",
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
"importFailedError": "設定のインポートに失敗しました: {{message}}", "importFailedError": "設定のインポートに失敗しました: {{message}}",
"exportFailedError": "設定のエクスポートに失敗しました:", "exportFailedError": "設定のエクスポートに失敗しました:",
"restartRequired": "再起動が必要です", "restartRequired": "再起動が必要です",
@@ -264,7 +273,8 @@
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引", "zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ", "packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から", "minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF" "minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF",
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!"
}, },
"parameterConfig": "パラメーター設定 - {{name}} *", "parameterConfig": "パラメーター設定 - {{name}} *",
"mainModel": "メインモデル(任意)", "mainModel": "メインモデル(任意)",
+14 -4
View File
@@ -17,6 +17,7 @@
"about": "关于", "about": "关于",
"version": "版本", "version": "版本",
"loading": "加载中...", "loading": "加载中...",
"notInstalled": "未安装",
"success": "成功", "success": "成功",
"error": "错误", "error": "错误",
"unknown": "未知", "unknown": "未知",
@@ -28,7 +29,10 @@
"formatError": "格式化失败:{{error}}", "formatError": "格式化失败:{{error}}",
"copy": "复制", "copy": "复制",
"view": "查看", "view": "查看",
"back": "返回" "back": "返回",
"refresh": "刷新",
"refreshing": "刷新中...",
"notInstalled": "未安装"
}, },
"apiKeyInput": { "apiKeyInput": {
"placeholder": "请输入API Key", "placeholder": "请输入API Key",
@@ -146,7 +150,7 @@
"themeDark": "深色", "themeDark": "深色",
"themeSystem": "跟随系统", "themeSystem": "跟随系统",
"importExport": "SQL 导入导出", "importExport": "SQL 导入导出",
"importExportHint": "导入/导出数据库 SQL 备份,便于备份或迁移。", "importExportHint": "导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。",
"exportConfig": "导出 SQL 备份", "exportConfig": "导出 SQL 备份",
"selectConfigFile": "选择 SQL 文件", "selectConfigFile": "选择 SQL 文件",
"noFileSelected": "尚未选择配置文件。", "noFileSelected": "尚未选择配置文件。",
@@ -162,7 +166,7 @@
"selectFileFailed": "请选择有效的 SQL 备份文件", "selectFileFailed": "请选择有效的 SQL 备份文件",
"configCorrupted": "SQL 文件可能已损坏或格式不正确", "configCorrupted": "SQL 文件可能已损坏或格式不正确",
"backupId": "备份ID", "backupId": "备份ID",
"autoReload": "数据将在2秒后自动刷新...", "autoReload": "数据已刷新",
"languageOptionChinese": "中文", "languageOptionChinese": "中文",
"languageOptionEnglish": "English", "languageOptionEnglish": "English",
"languageOptionJapanese": "日本語", "languageOptionJapanese": "日本語",
@@ -205,6 +209,11 @@
"releaseNotes": "更新日志", "releaseNotes": "更新日志",
"viewReleaseNotes": "查看该版本更新日志", "viewReleaseNotes": "查看该版本更新日志",
"viewCurrentReleaseNotes": "查看当前版本更新日志", "viewCurrentReleaseNotes": "查看当前版本更新日志",
"oneClickInstall": "一键安装",
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI",
"localEnvCheck": "本地环境检查",
"installCommandsCopied": "安装命令已复制",
"installCommandsCopyFailed": "复制失败,请手动复制。",
"importFailedError": "导入配置失败:{{message}}", "importFailedError": "导入配置失败:{{message}}",
"exportFailedError": "导出配置失败:", "exportFailedError": "导出配置失败:",
"restartRequired": "需要重启应用", "restartRequired": "需要重启应用",
@@ -264,7 +273,8 @@
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠", "zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠", "packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起", "minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)" "minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!"
}, },
"parameterConfig": "参数配置 - {{name}} *", "parameterConfig": "参数配置 - {{name}} *",
"mainModel": "主模型 (可选)", "mainModel": "主模型 (可选)",
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -45,6 +45,10 @@ export const icons: Record<string, string> = {
yi: `<svg fill="currentColor" 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>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>`, yi: `<svg fill="currentColor" 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>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>`,
zeroone: `<svg fill="currentColor" 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>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`, zeroone: `<svg fill="currentColor" 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>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`,
zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`, zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`,
openrouter: `<svg fill="currentColor" 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>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>`,
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
}; };
export const iconList = Object.keys(icons); export const iconList = Object.keys(icons);
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>

After

Width:  |  Height:  |  Size: 819 B

+28
View File
@@ -303,6 +303,34 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["chatglm", "glm"], keywords: ["chatglm", "glm"],
defaultColor: "#0F62FE", defaultColor: "#0F62FE",
}, },
openrouter: {
name: "openrouter",
displayName: "OpenRouter",
category: "ai-provider",
keywords: ["openrouter", "router", "aggregator"],
defaultColor: "#6566F1",
},
longcat: {
name: "longcat",
displayName: "LongCat",
category: "ai-provider",
keywords: ["longcat", "long", "cat"],
defaultColor: "#29E154",
},
modelscope: {
name: "modelscope",
displayName: "ModelScope",
category: "ai-provider",
keywords: ["modelscope", "alibaba", "scope"],
defaultColor: "#624AFF",
},
aihubmix: {
name: "aihubmix",
displayName: "AiHubMix",
category: "ai-provider",
keywords: ["aihubmix", "hub", "mix", "aggregator"],
defaultColor: "#006FFB",
},
}; };
export function getIconMetadata(name: string): IconMetadata | undefined { export function getIconMetadata(name: string): IconMetadata | undefined {
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>

After

Width:  |  Height:  |  Size: 632 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" 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>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>

After

Width:  |  Height:  |  Size: 906 B

+6 -1
View File
@@ -1,5 +1,4 @@
export interface ProxyConfig { export interface ProxyConfig {
enabled: boolean;
listen_address: string; listen_address: string;
listen_port: number; listen_port: number;
max_retries: number; max_retries: number;
@@ -38,6 +37,12 @@ export interface ProxyServerInfo {
started_at: string; started_at: string;
} }
export interface ProxyTakeoverStatus {
claude: boolean;
codex: boolean;
gemini: boolean;
}
export interface ProviderHealth { export interface ProviderHealth {
provider_id: string; provider_id: string;
app_type: string; app_type: string;
-6
View File
@@ -110,12 +110,6 @@ describe("useImportExport Hook", () => {
expect(result.current.status).toBe("success"); expect(result.current.status).toBe("success");
expect(result.current.backupId).toBe("backup-123"); expect(result.current.backupId).toBe("backup-123");
expect(toastSuccessMock).toHaveBeenCalledTimes(1); expect(toastSuccessMock).toHaveBeenCalledTimes(1);
// Skip delay to execute callback
await act(async () => {
vi.runOnlyPendingTimers();
});
expect(onImportSuccess).toHaveBeenCalledTimes(1); expect(onImportSuccess).toHaveBeenCalledTimes(1);
}); });
+8
View File
@@ -258,6 +258,14 @@ export const handlers = [
}), }),
), ),
http.post(`${TAURI_ENDPOINT}/get_proxy_takeover_status`, () =>
success({
claude: false,
codex: false,
gemini: false,
}),
),
http.post(`${TAURI_ENDPOINT}/is_live_takeover_active`, () => success(false)), http.post(`${TAURI_ENDPOINT}/is_live_takeover_active`, () => success(false)),
// Failover / circuit breaker defaults // Failover / circuit breaker defaults