Compare commits

..

61 Commits

Author SHA1 Message Date
YoVinchen 621be9a466 refactor(proxy): load circuit breaker config per-app instead of globally
Extract app_type from router key and read circuit breaker settings
from the corresponding proxy_config row for each application.
2025-12-25 00:02:35 +08:00
YoVinchen 937978d68a feat(i18n): add proxy takeover translations and update types
Add i18n strings for proxy takeover status in zh/en/ja.
Update TypeScript types for GlobalProxyConfig and AppProxyConfig.
2025-12-24 23:48:41 +08:00
YoVinchen 298d19af89 refactor(ui): redesign proxy panel with inline config controls
Replace ProxySettingsDialog with inline controls in ProxyPanel.
Add per-app takeover switches and global address/port settings.
Simplify AutoFailoverConfigPanel by removing timeout settings.
2025-12-24 23:47:07 +08:00
YoVinchen 5fcddbd096 feat(api): add frontend API and Query hooks for proxy config
Add TypeScript wrappers and TanStack Query hooks for:
- Global proxy config (address, port, logging)
- Per-app proxy config (failover, timeouts, circuit breaker)
- Proxy takeover status management
2025-12-24 23:46:06 +08:00
YoVinchen 00a94e118f feat(commands): add global and per-app proxy config commands
Add new Tauri commands for the refactored proxy configuration:
- get_global_proxy_config / update_global_proxy_config
- get_proxy_config_for_app / update_proxy_config_for_app
Update startup restore logic to read from proxy_config table.
2025-12-24 23:45:03 +08:00
YoVinchen c09e0378b9 refactor(proxy): update service layer for per-app config structure
Adapt proxy service, handler context, and provider router to use
the new per-app configuration model. Read enabled/timeout settings
from proxy_config table instead of settings table.
2025-12-24 23:43:52 +08:00
YoVinchen 30004037e5 feat(proxy): add GlobalProxyConfig and AppProxyConfig types
Add new type definitions for the refactored proxy configuration:
- GlobalProxyConfig: shared settings (enabled, address, port, logging)
- AppProxyConfig: per-app settings (failover, timeouts, circuit breaker)
2025-12-24 23:43:24 +08:00
YoVinchen d6ed95078c refactor(database): migrate proxy_config to per-app three-row structure
Replace singleton proxy_config table with app_type primary key structure,
allowing independent proxy settings for Claude, Codex, and Gemini.
Add GlobalProxyConfig queries and per-app config management in DAO layer.
2025-12-24 23:42:27 +08:00
YoVinchen e27b8ee31f refactor(ui): remove timeout settings from AutoFailoverConfigPanel
Remove streaming/non-streaming timeout configuration from failover panel
as these settings have been moved to a dedicated location.
2025-12-24 16:11:12 +08:00
YoVinchen 3d7b056df1 feat(stream-check): use provider-configured model for health checks
Extract model from provider's settings_config (ANTHROPIC_MODEL, GEMINI_MODEL,
or Codex config.toml) instead of always using default test models.
2025-12-24 14:50:54 +08:00
YoVinchen 69f3c78bbf feat(ui): add OpenRouter compatibility mode toggle
Add UI toggle for OpenRouter providers to enable/disable compatibility
mode which uses OpenAI Chat Completions format with SSE conversion.
2025-12-24 14:42:46 +08:00
YoVinchen 9b1c659f07 feat(proxy): add openrouter_compat_mode for optional format conversion
Add configurable OpenRouter compatibility mode that enables Anthropic to
OpenAI format conversion. When enabled, rewrites endpoint to /v1/chat/completions
and transforms request/response formats. Defaults to enabled for OpenRouter.
2025-12-24 13:53:19 +08:00
YoVinchen e1720a0dd1 feat(ui): add reasoning model field to Claude provider form
Add ANTHROPIC_REASONING_MODEL configuration field for Claude providers,
allowing users to specify a dedicated model for thinking/reasoning tasks.
2025-12-24 12:34:58 +08:00
YoVinchen 85998600a1 fix(proxy): bypass circuit breaker for single provider scenario
When failover is disabled (single provider), circuit breaker open state
would block all requests causing poor UX. Now bypasses circuit breaker
check in this scenario. Also integrates model mapping into request flow.
2025-12-24 12:32:57 +08:00
YoVinchen 2b1fd0582e feat(proxy): add model mapping module for provider-based model substitution
- Add model_mapper.rs with ModelMapping struct to extract model configs from Provider
- Support ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, and default models for haiku/sonnet/opus
- Implement thinking mode detection for reasoning model priority
- Include comprehensive unit tests for all mapping scenarios
2025-12-24 12:20:49 +08:00
YoVinchen dbdaf35770 feat(proxy): implement streaming timeout control with validation
- Add first byte timeout (0 or 1-180s) for streaming requests
- Add idle timeout (0 or 60-600s) for streaming data gaps
- Add non-streaming timeout (0 or 60-1800s) for total request
- Implement timeout logic in response processor
- Add 1800s global timeout fallback when disabled
- Add database schema migration for timeout fields
- Add i18n translations for timeout settings
2025-12-24 10:03:54 +08:00
YoVinchen c6f4a54c98 feat(proxy): extract model name from API response for accurate usage tracking
- Add model field extraction in TokenUsage parsing for Claude, OpenAI, and Codex
- Prioritize response model over request model in usage logging
- Update model extractors to use parsed usage.model first
- Add tests for model extraction in stream and non-stream responses
2025-12-23 23:49:16 +08:00
Jason 7d495aa772 feat(ui): add exit animation to FullScreenPanel dialogs
- Wrap FullScreenPanel content with AnimatePresence for exit animation
- Add exit={{ opacity: 0 }} to enable fade-out on close
- Use useRef + useEffect to preserve provider data during exit animation
- Follow React best practices by updating refs in useEffect instead of render

Affected components:
- EditProviderDialog
- UsageScriptModal
- AddProviderDialog
- McpFormModal
- ProxySettingsDialog
2025-12-23 16:18:28 +08:00
Jason db8180aa31 fix(ui): reduce header spacing and fix layout shift on view switch
- Change right-side button container from min-h-[40px] to h-[32px]
  for more compact header layout
- Remove conditional padding (pt-6/pt-4) from main content area
  to eliminate layout jump during view transitions
2025-12-23 16:18:28 +08:00
YoVinchen 1586451862 Feat/auto failover switch (#440)
* feat(failover): add auto-failover master switch with proxy integration

- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle

* feat(proxy): persist proxy takeover state across app restarts

- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown

* fix(proxy): set takeover state for all apps in start_with_takeover

* fix(windows): hide console window when checking CLI versions

Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.

* refactor(failover): make auto-failover toggle per-app independent

- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior

* feat(failover): auto-switch to higher priority provider on recovery

- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded

* chore: remove unused start_proxy_with_takeover command

- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop

* feat(ui): integrate failover controls into provider cards

- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section

* test(providers): add mocks for failover hooks in ProviderList tests

* refactor(failover): merge failover_queue table into providers

- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type

* fix(database): ensure in_failover_queue column exists for v2 databases

Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.

* fix(ui): differentiate active provider border color by proxy mode

- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states

* fix(database): clear provider health record when removing from failover queue

When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.

* fix(failover): improve cache cleanup for provider health and circuit breaker

- Use removeQueries instead of invalidateQueries when stopping proxy to
  completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
  failover queue
- Refresh failover queue after drag-sort since queue order depends on
  sort_index
- Only show health badge when provider is in failover queue

* style: apply prettier formatting to App.tsx and ProviderList.tsx

* fix(proxy): handle missing health records and clear health on proxy stop

- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover

* fix(proxy): track actual provider used in forwarding for accurate logging

Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
2025-12-23 12:37:36 +08:00
Jason bf570b6d2a fix(ui): prevent header layout shift when switching views
Add min-height to right-side button container and ml-auto to add buttons
in MCP/Prompts views to maintain consistent header height and button
position across all views.
2025-12-22 16:54:49 +08:00
Jason 26c3f05daf feat(ui): add fade transition for view and panel switching
Add smooth fade animations when navigating between views (Settings,
MCP, Skills, Prompts) and opening full-screen panels (Add/Edit Provider).
2025-12-22 16:54:49 +08:00
TinsFox d303706d51 feat: add provider search filter (#435)
* feat: add provider search filter

* feat: add provider search overlay
2025-12-22 15:46:11 +08:00
TinsFox 97495d1550 Remove macOS titlebar tint and align custom header (#438) 2025-12-22 15:43:28 +08:00
YoVinchen a1537807eb feat(failover): add auto-failover master switch with proxy integration (#427)
* feat(failover): add auto-failover master switch with proxy integration

- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle

* feat(proxy): persist proxy takeover state across app restarts

- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown

* fix(proxy): set takeover state for all apps in start_with_takeover
2025-12-21 22:39:50 +08:00
TinsFox f047960a33 Use macOS tray template icon (#434) 2025-12-21 21:10:00 +08:00
TinsFox ace9b38cee style: remove shadow (#431) 2025-12-21 21:09:37 +08:00
Jason b67cdbb18c feat(ui): add fade transition for app switching
- Add AnimatePresence + motion.div wrapper for provider list
- Use key={activeApp} to trigger enter/exit animations on app switch
- Remove redundant animate-slide-up from ProviderList to prevent
  animation conflicts and visual jitter
2025-12-21 10:27:56 +08:00
Jason c4f1e90893 style(settings): unify tab transition animations
Add framer-motion fade-in and slide-up animations to General and
Advanced tabs, matching the existing Usage and About tab animations.
2025-12-21 09:29:14 +08:00
Jason ddbff070d5 feat(settings): add option to skip Claude Code first-run confirmation
Add a new setting to automatically skip Claude Code's onboarding screen
by writing hasCompletedOnboarding=true to ~/.claude.json. The setting
defaults to enabled for better user experience.

- Add set/clear_has_completed_onboarding functions in claude_mcp.rs
- Add Tauri commands and frontend API integration
- Add toggle in WindowSettings with i18n support (en/zh/ja)
- Fix hardcoded Chinese text in tests to use i18n keys
2025-12-20 23:55:10 +08:00
Jason ca7cb398c2 i18n: complete usage panel and settings internationalization
- Add missing i18n keys for usage statistics panel (trends, cost, perMillion, etc.)
- Add i18n keys for settings advanced section (configDir, proxy, modelTest, etc.)
- Add streamCheck i18n keys for health check configuration
- Remove hardcoded Chinese fallback values from t() calls
- Add common keys (all, search, reset, actions, deleting)
2025-12-20 22:29:39 +08:00
Jason 2fb3b5405a i18n: complete internationalization for v3.8+ features
- Add health status translations (operational, degraded, failed, circuitOpen)
- Add proxy panel translations (serviceAddress, stats, stopped state)
- Add usage filter translations (appType, statusCode, searchPlaceholder)
- Add providerIcon click hints (clickToChange, clickToSelect)
- Add config load error translations for main.tsx
- Complete Japanese proxy section (failoverQueue, autoFailover)
- Fix date/time locale in usage charts and tables
- Use t() function in all hardcoded UI strings
2025-12-20 21:38:37 +08:00
Jason ec649e7718 style(switch): improve dark mode appearance
- Track (unchecked): lighten in light mode (gray-300 → gray-200),
  darken in dark mode (gray-700 → gray-900) to blend with background
- Thumb: soften in dark mode (white → gray-400) to reduce glare
2025-12-20 19:18:29 +08:00
Jason 3da5525c79 fix(ui): improve text visibility in dark mode
Replace hardcoded gray color classes with semantic color classes
to fix poor text contrast in dark mode:

- MCP panel: server names, descriptions, tags, app labels
- Prompt panel: prompt names, descriptions, empty states
- Usage footer: timestamps, refresh buttons
- Update badge: close button icon
- API key input: disabled state text
- Env warning banner: source info text

Changes:
- `text-gray-400 dark:text-gray-500` → `text-muted-foreground`
  (fixes reversed dark mode logic)
- `text-gray-500 dark:text-gray-400` → `text-muted-foreground`
- `bg-gray-100 dark:bg-gray-800` → `bg-muted`
2025-12-20 18:57:36 +08:00
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
132 changed files with 8196 additions and 2551 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",
+57 -5
View File
@@ -586,6 +586,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.10.1" version = "1.10.1"
@@ -695,7 +701,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",
@@ -2214,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"png", "png 0.17.16",
] ]
[[package]] [[package]]
@@ -2330,6 +2336,19 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.0",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -2759,6 +2778,16 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "moxcms"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.1"
@@ -2774,7 +2803,7 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png", "png 0.17.16",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -3563,6 +3592,19 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.9.4",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -3694,6 +3736,15 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -4987,6 +5038,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range", "http-range",
"image",
"jni", "jni",
"libc", "libc",
"log", "log",
@@ -5055,7 +5107,7 @@ dependencies = [
"ico", "ico",
"json-patch", "json-patch",
"plist", "plist",
"png", "png 0.17.16",
"proc-macro2", "proc-macro2",
"quote", "quote",
"semver", "semver",
@@ -5810,7 +5862,7 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"once_cell", "once_cell",
"png", "png 0.17.16",
"serde", "serde",
"thiserror 2.0.17", "thiserror 2.0.17",
"windows-sys 0.59.0", "windows-sys 0.59.0",
+2 -2
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"
@@ -26,7 +26,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] } tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+49
View File
@@ -105,6 +105,55 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content)) Ok(Some(content))
} }
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
/// 仅增量写入该字段,其他字段保持不变
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
let mut root = if path.exists() {
read_json_value(&path)?
} else {
serde_json::json!({})
};
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let already = obj
.get("hasCompletedOnboarding")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if already {
return Ok(false);
}
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
write_json_value(&path, &root)?;
Ok(true)
}
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
/// 仅增量删除该字段,其他字段保持不变
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(false);
}
let mut root = read_json_value(&path)?;
let obj = root
.as_object_mut()
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
let existed = obj.remove("hasCompletedOnboarding").is_some();
if !existed {
return Ok(false);
}
write_json_value(&path, &root)?;
Ok(true)
}
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> { pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if id.trim().is_empty() { if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into())); return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
+28 -10
View File
@@ -1,6 +1,6 @@
//! 故障转移队列命令 //! 故障转移队列命令
//! //!
//! 管理代理模式下的故障转移队列 //! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::FailoverQueueItem; use crate::database::FailoverQueueItem;
use crate::provider::Provider; use crate::provider::Provider;
@@ -56,29 +56,47 @@ pub async fn remove_from_failover_queue(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// 重新排序故障转移队列 /// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
#[tauri::command] #[tauri::command]
pub async fn reorder_failover_queue( pub async fn get_auto_failover_enabled(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app_type: String, app_type: String,
provider_ids: Vec<String>, ) -> Result<bool, String> {
) -> Result<(), String> {
state state
.db .db
.reorder_failover_queue(&app_type, &provider_ids) .get_proxy_config_for_app(&app_type)
.await
.map(|config| config.auto_failover_enabled)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// 设置故障转移队列项的启用状态 /// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
///
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
#[tauri::command] #[tauri::command]
pub async fn set_failover_item_enabled( pub async fn set_auto_failover_enabled(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app_type: String, app_type: String,
provider_id: String,
enabled: bool, enabled: bool,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!(
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
);
// 读取当前配置
let mut config = state
.db
.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())?;
// 更新 auto_failover_enabled 字段
config.auto_failover_enabled = enabled;
// 写回数据库
state state
.db .db
.set_failover_item_enabled(&app_type, &provider_id, enabled) .update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
+42 -9
View File
@@ -4,6 +4,12 @@ use crate::init_status::InitErrorPayload;
use tauri::AppHandle; use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// 打开外部链接 /// 打开外部链接
#[tauri::command] #[tauri::command]
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> { pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
@@ -142,11 +148,16 @@ fn extract_version(raw: &str) -> String {
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) { fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
use std::process::Command; use std::process::Command;
let output = if cfg!(target_os = "windows") { #[cfg(target_os = "windows")]
let output = {
Command::new("cmd") Command::new("cmd")
.args(["/C", &format!("{tool} --version")]) .args(["/C", &format!("{tool} --version")])
.creation_flags(CREATE_NO_WINDOW)
.output() .output()
} else { };
#[cfg(not(target_os = "windows"))]
let output = {
Command::new("sh") Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!("{tool} --version")) .arg(format!("{tool} --version"))
@@ -155,11 +166,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() {
@@ -233,15 +250,31 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
let current_path = std::env::var("PATH").unwrap_or_default(); let current_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", path.display(), current_path); let new_path = format!("{}:{}", path.display(), current_path);
let output = Command::new(&tool_path) #[cfg(target_os = "windows")]
let output = {
Command::new(&tool_path)
.arg("--version") .arg("--version")
.env("PATH", &new_path) .env("PATH", &new_path)
.output(); .creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.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);
}
} }
} }
} }
+12
View File
@@ -34,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
pub async fn is_claude_plugin_applied() -> Result<bool, String> { pub async fn is_claude_plugin_applied() -> Result<bool, String> {
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string()) crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
} }
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true
#[tauri::command]
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
}
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
#[tauri::command]
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
}
+148 -3
View File
@@ -6,12 +6,12 @@ use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState; use crate::store::AppState;
/// 启动代理服务器( Live 配置接管 /// 启动代理服务器(仅启动服务,不接管 Live 配置)
#[tauri::command] #[tauri::command]
pub async fn start_proxy_with_takeover( pub async fn start_proxy_server(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> { ) -> Result<ProxyServerInfo, String> {
state.proxy_service.start_with_takeover().await state.proxy_service.start().await
} }
/// 停止代理服务器(恢复 Live 配置) /// 停止代理服务器(恢复 Live 配置)
@@ -20,6 +20,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> {
@@ -41,6 +62,63 @@ pub async fn update_proxy_config(
state.proxy_service.update_config(&config).await state.proxy_service.update_config(&config).await
} }
// ==================== Global & Per-App Config ====================
/// 获取全局代理配置
///
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
#[tauri::command]
pub async fn get_global_proxy_config(
state: tauri::State<'_, AppState>,
) -> Result<GlobalProxyConfig, String> {
let db = &state.db;
db.get_global_proxy_config()
.await
.map_err(|e| e.to_string())
}
/// 更新全局代理配置
///
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini
#[tauri::command]
pub async fn update_global_proxy_config(
state: tauri::State<'_, AppState>,
config: GlobalProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_global_proxy_config(config)
.await
.map_err(|e| e.to_string())
}
/// 获取指定应用的代理配置
///
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn get_proxy_config_for_app(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<AppProxyConfig, String> {
let db = &state.db;
db.get_proxy_config_for_app(&app_type)
.await
.map_err(|e| e.to_string())
}
/// 更新指定应用的代理配置
///
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
#[tauri::command]
pub async fn update_proxy_config_for_app(
state: tauri::State<'_, AppState>,
config: AppProxyConfig,
) -> Result<(), String> {
let db = &state.db;
db.update_proxy_config_for_app(config)
.await
.map_err(|e| e.to_string())
}
/// 检查代理服务器是否正在运行 /// 检查代理服务器是否正在运行
#[tauri::command] #[tauri::command]
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> { pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
@@ -82,8 +160,13 @@ pub async fn get_provider_health(
} }
/// 重置熔断器 /// 重置熔断器
///
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
/// 1. 检查自动故障转移是否开启
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
#[tauri::command] #[tauri::command]
pub async fn reset_circuit_breaker( pub async fn reset_circuit_breaker(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
provider_id: String, provider_id: String,
app_type: String, app_type: String,
@@ -100,6 +183,68 @@ pub async fn reset_circuit_breaker(
.reset_provider_circuit_breaker(&provider_id, &app_type) .reset_provider_circuit_breaker(&provider_id, &app_type)
.await?; .await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
false
}
};
if auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID
let current_id = db
.get_current_provider(&app_type)
.map_err(|e| e.to_string())?;
if let Some(current_id) = current_id {
// 获取故障转移队列
let queue = db
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index
let restored_order = queue
.iter()
.find(|item| item.provider_id == provider_id)
.and_then(|item| item.sort_index);
let current_order = queue
.iter()
.find(|item| item.provider_id == current_id)
.and_then(|item| item.sort_index);
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
if let (Some(restored), Some(current)) = (restored_order, current_order) {
if restored < current {
log::info!(
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
);
// 获取供应商名称用于日志和事件
let provider_name = db
.get_all_providers(&app_type)
.ok()
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
.unwrap_or_else(|| provider_id.clone());
// 创建故障转移切换管理器并执行切换
let switch_manager =
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
if let Err(e) = switch_manager
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
.await
{
log::error!("[Recovery] 自动切换失败: {e}");
}
}
}
}
}
Ok(()) Ok(())
} }
-2
View File
@@ -52,11 +52,9 @@ pub async fn stream_check_all_providers(
} }
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) { if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
for item in queue { for item in queue {
if item.enabled {
ids.insert(item.provider_id); ids.insert(item.provider_id);
} }
} }
}
Some(ids) Some(ids)
} else { } else {
None None
+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);
+33 -128
View File
@@ -1,36 +1,32 @@
//! 故障转移队列 DAO //! 故障转移队列 DAO
//! //!
//! 管理代理模式下的故障转移队列 //! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::{lock_conn, Database}; use crate::database::{lock_conn, Database};
use crate::error::AppError; use crate::error::AppError;
use crate::provider::Provider; use crate::provider::Provider;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// 故障转移队列条目 /// 故障转移队列条目(简化版,用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FailoverQueueItem { pub struct FailoverQueueItem {
pub provider_id: String, pub provider_id: String,
pub provider_name: String, pub provider_name: String,
pub queue_order: i32, pub sort_index: Option<usize>,
pub enabled: bool,
pub created_at: i64,
} }
impl Database { impl Database {
/// 获取故障转移队列(按 queue_order 排序) /// 获取故障转移队列(按 sort_index 排序)
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> { pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at "SELECT id, name, sort_index
FROM failover_queue fq FROM providers
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type WHERE app_type = ?1 AND in_failover_queue = 1
WHERE fq.app_type = ?1 ORDER BY COALESCE(sort_index, 999999), id ASC",
ORDER BY fq.queue_order ASC",
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -39,9 +35,7 @@ impl Database {
Ok(FailoverQueueItem { Ok(FailoverQueueItem {
provider_id: row.get(0)?, provider_id: row.get(0)?,
provider_name: row.get(1)?, provider_name: row.get(1)?,
queue_order: row.get(2)?, sort_index: row.get(2)?,
enabled: row.get(3)?,
created_at: row.get(4)?,
}) })
}) })
.map_err(|e| AppError::Database(e.to_string()))? .map_err(|e| AppError::Database(e.to_string()))?
@@ -53,43 +47,23 @@ impl Database {
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序) /// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> { pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let queue = self.get_failover_queue(app_type)?;
let all_providers = self.get_all_providers(app_type)?; let all_providers = self.get_all_providers(app_type)?;
let mut result = Vec::new(); let result: Vec<Provider> = all_providers
for item in queue { .into_values()
if item.enabled { .filter(|p| p.in_failover_queue)
if let Some(provider) = all_providers.get(&item.provider_id) { .collect();
result.push(provider.clone());
}
}
}
Ok(result) Ok(result)
} }
/// 添加供应商到故障转移队列末尾 /// 添加供应商到故障转移队列
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> { pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order
let max_order: i32 = conn
.query_row(
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute( conn.execute(
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at) "UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
VALUES (?1, ?2, ?3, 1, ?4)", rusqlite::params![provider_id, app_type],
rusqlite::params![app_type, provider_id, max_order + 1, now],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -104,89 +78,21 @@ impl Database {
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 获取被删除项的 queue_order // 1. 从队列中移除
let removed_order: Option<i32> = conn
.query_row(
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
|row| row.get(0),
)
.ok();
// 删除该项
conn.execute( conn.execute(
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", "UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
[app_type, provider_id], rusqlite::params![provider_id, app_type],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 重新排序后面的项(填补空隙 // 2. 清除该供应商的健康状态(退出队列后不再需要健康监控
if let Some(order) = removed_order {
conn.execute( conn.execute(
"UPDATE failover_queue "DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
SET queue_order = queue_order - 1 rusqlite::params![provider_id, app_type],
WHERE app_type = ?1 AND queue_order > ?2",
rusqlite::params![app_type, order],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(()) log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
}
/// 重新排序故障转移队列
/// provider_ids: 按新顺序排列的 provider_id 列表
pub fn reorder_failover_queue(
&self,
app_type: &str,
provider_ids: &[String],
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 使用事务确保原子性
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let result = (|| {
for (index, provider_id) in provider_ids.iter().enumerate() {
conn.execute(
"UPDATE failover_queue
SET queue_order = ?3
WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, (index + 1) as i32],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
})();
match result {
Ok(_) => {
conn.execute("COMMIT", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
Err(e) => {
conn.execute("ROLLBACK", []).ok();
Err(e)
}
}
}
/// 设置故障转移队列中供应商的启用状态
pub fn set_failover_item_enabled(
&self,
app_type: &str,
provider_id: &str,
enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, enabled],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -195,7 +101,10 @@ 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("DELETE FROM failover_queue WHERE app_type = ?1", [app_type]) conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
@@ -209,15 +118,15 @@ impl Database {
) -> Result<bool, AppError> { ) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let count: i32 = conn let in_queue: bool = conn
.query_row( .query_row(
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2", "SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
[app_type, provider_id], rusqlite::params![provider_id, app_type],
|row| row.get(0), |row| row.get(0),
) )
.map_err(|e| AppError::Database(e.to_string()))?; .unwrap_or(false);
Ok(count > 0) Ok(in_queue)
} }
/// 获取可添加到故障转移队列的供应商(不在队列中的) /// 获取可添加到故障转移队列的供应商(不在队列中的)
@@ -226,14 +135,10 @@ impl Database {
app_type: &str, app_type: &str,
) -> Result<Vec<Provider>, AppError> { ) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?; let all_providers = self.get_all_providers(app_type)?;
let queue = self.get_failover_queue(app_type)?;
let queue_ids: std::collections::HashSet<_> =
queue.iter().map(|item| &item.provider_id).collect();
let available: Vec<Provider> = all_providers let available: Vec<Provider> = all_providers
.into_values() .into_values()
.filter(|p| !queue_ids.contains(&p.id)) .filter(|p| !p.in_failover_queue)
.collect(); .collect();
Ok(available) Ok(available)
+19 -11
View File
@@ -17,7 +17,7 @@ impl Database {
) -> Result<IndexMap<String, Provider>, AppError> { ) -> Result<IndexMap<String, Provider>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE app_type = ?1 FROM providers WHERE app_type = ?1
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?; ).map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,6 +35,7 @@ impl Database {
let icon: Option<String> = row.get(8)?; let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = row.get(9)?; let icon_color: Option<String> = row.get(9)?;
let meta_str: String = row.get(10)?; let meta_str: String = row.get(10)?;
let in_failover_queue: bool = row.get(11)?;
let settings_config = let settings_config =
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
@@ -54,6 +55,7 @@ impl Database {
meta: Some(meta), meta: Some(meta),
icon, icon,
icon_color, icon_color,
in_failover_queue,
}, },
)) ))
}) })
@@ -129,7 +131,7 @@ impl Database {
) -> Result<Option<Provider>, AppError> { ) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let result = conn.query_row( let result = conn.query_row(
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta "SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE id = ?1 AND app_type = ?2", FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type], params![id, app_type],
|row| { |row| {
@@ -143,6 +145,7 @@ impl Database {
let icon: Option<String> = row.get(7)?; let icon: Option<String> = row.get(7)?;
let icon_color: Option<String> = row.get(8)?; let icon_color: Option<String> = row.get(8)?;
let meta_str: String = row.get(9)?; let meta_str: String = row.get(9)?;
let in_failover_queue: bool = row.get(10)?;
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null); let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default(); let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
@@ -159,6 +162,7 @@ impl Database {
meta: Some(meta), meta: Some(meta),
icon, icon,
icon_color, icon_color,
in_failover_queue,
}) })
}, },
); );
@@ -184,17 +188,18 @@ impl Database {
let mut meta_clone = provider.meta.clone().unwrap_or_default(); let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<bool> = tx let existing: Option<(bool, bool)> = tx
.query_row( .query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2", "SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type], params![provider.id, app_type],
|row| row.get(0), |row| Ok((row.get(0)?, row.get(1)?)),
) )
.ok(); .ok();
let is_update = existing.is_some(); let is_update = existing.is_some();
let is_current = existing.unwrap_or(false); let (is_current, in_failover_queue) =
existing.unwrap_or((false, provider.in_failover_queue));
if is_update { if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
@@ -210,8 +215,9 @@ impl Database {
icon = ?8, icon = ?8,
icon_color = ?9, icon_color = ?9,
meta = ?10, meta = ?10,
is_current = ?11 is_current = ?11,
WHERE id = ?12 AND app_type = ?13", in_failover_queue = ?12
WHERE id = ?13 AND app_type = ?14",
params![ params![
provider.name, provider.name,
serde_json::to_string(&provider.settings_config).unwrap(), serde_json::to_string(&provider.settings_config).unwrap(),
@@ -224,6 +230,7 @@ impl Database {
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone).unwrap(),
is_current, is_current,
in_failover_queue,
provider.id, provider.id,
app_type, app_type,
], ],
@@ -234,8 +241,8 @@ impl Database {
tx.execute( tx.execute(
"INSERT INTO providers ( "INSERT INTO providers (
id, app_type, name, settings_config, website_url, category, id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![ params![
provider.id, provider.id,
app_type, app_type,
@@ -250,6 +257,7 @@ impl Database {
provider.icon_color, provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), serde_json::to_string(&meta_clone).unwrap(),
is_current, is_current,
in_failover_queue,
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
+304 -58
View File
@@ -8,63 +8,66 @@ use crate::proxy::types::*;
use super::super::{lock_conn, Database}; use super::super::{lock_conn, Database};
impl Database { impl Database {
// ==================== Proxy Config ==================== // ==================== Global Proxy Config ====================
/// 获取代理配置 /// 获取全局代理配置(统一字段)
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> { ///
// 在一个作用域内获取锁并查询,确保锁在await之前释放 /// 从 claude 行读取(三行镜像一致)
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
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 proxy_enabled, listen_address, listen_port, enable_logging
request_timeout, enable_logging, live_takeover_active FROM proxy_config WHERE app_type = 'claude'",
FROM proxy_config WHERE id = 1",
[], [],
|row| { |row| {
Ok(ProxyConfig { Ok(GlobalProxyConfig {
enabled: row.get::<_, i32>(0)? != 0, proxy_enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?, listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16, listen_port: row.get::<_, i32>(2)? as u16,
max_retries: row.get::<_, i32>(3)? as u8, enable_logging: row.get::<_, i32>(3)? != 0,
request_timeout: row.get::<_, i32>(4)? as u64,
enable_logging: row.get::<_, i32>(5)? != 0,
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
}) })
}, },
) )
}; // conn锁在这里释放 };
// conn 已在 block 结束时释放
match result { match result {
Ok(config) => Ok(config), Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => { Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,插入默认配置 // 如果不存在,创建默认配置
let default_config = ProxyConfig::default(); self.init_proxy_config_rows().await?;
self.update_proxy_config(default_config.clone()).await?; Ok(GlobalProxyConfig {
Ok(default_config) proxy_enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 5000,
enable_logging: true,
})
} }
Err(e) => Err(AppError::Database(e.to_string())), Err(e) => Err(AppError::Database(e.to_string())),
} }
} }
/// 更新代理配置 /// 更新全局代理配置(镜像写三行)
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> { pub async fn update_global_proxy_config(
&self,
config: GlobalProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.execute( conn.execute(
"INSERT OR REPLACE INTO proxy_config "UPDATE proxy_config SET
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at) proxy_enabled = ?1,
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, listen_address = ?2,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), listen_port = ?3,
datetime('now'))", enable_logging = ?4,
updated_at = datetime('now')",
rusqlite::params![ rusqlite::params![
if config.enabled { 1 } else { 0 }, if config.proxy_enabled { 1 } else { 0 },
config.listen_address, config.listen_address,
config.listen_port as i32, config.listen_port as i32,
config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 }, if config.enable_logging { 1 } else { 0 },
if config.live_takeover_active { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值
], ],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -72,28 +75,214 @@ impl Database {
Ok(()) Ok(())
} }
/// 设置 Live 接管状态 /// 获取应用级代理配置
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> { pub async fn get_proxy_config_for_app(
&self,
app_type: &str,
) -> Result<AppProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let app_type_owned = app_type.to_string();
let result = {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT app_type, enabled, auto_failover_enabled,
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
FROM proxy_config WHERE app_type = ?1",
[app_type],
|row| {
Ok(AppProxyConfig {
app_type: row.get(0)?,
enabled: row.get::<_, i32>(1)? != 0,
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
max_retries: row.get::<_, i32>(3)? as u32,
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
circuit_error_rate_threshold: row.get(10)?,
circuit_min_requests: row.get::<_, i32>(11)? as u32,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,创建默认配置
self.init_proxy_config_rows().await?;
Ok(AppProxyConfig {
app_type: app_type_owned,
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 300,
circuit_failure_threshold: 5,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.5,
circuit_min_requests: 10,
})
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新应用级代理配置
pub async fn update_proxy_config_for_app(
&self,
config: AppProxyConfig,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute( conn.execute(
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1", "UPDATE proxy_config SET
rusqlite::params![if active { 1 } else { 0 }], enabled = ?2,
auto_failover_enabled = ?3,
max_retries = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
circuit_failure_threshold = ?8,
circuit_success_threshold = ?9,
circuit_timeout_seconds = ?10,
circuit_error_rate_threshold = ?11,
circuit_min_requests = ?12,
updated_at = datetime('now')
WHERE app_type = ?1",
rusqlite::params![
config.app_type,
if config.enabled { 1 } else { 0 },
if config.auto_failover_enabled { 1 } else { 0 },
config.max_retries as i32,
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
config.circuit_failure_threshold as i32,
config.circuit_success_threshold as i32,
config.circuit_timeout_seconds as i32,
config.circuit_error_rate_threshold,
config.circuit_min_requests as i32,
],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 初始化 proxy_config 表的三行数据
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
for app_type in &["claude", "codex", "gemini"] {
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
// ==================== Legacy Proxy Config (兼容旧代码) ====================
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT listen_address, listen_port, max_retries,
enable_logging,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
FROM proxy_config WHERE app_type = 'claude'",
[],
|row| {
Ok(ProxyConfig {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 300, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
})
},
)
};
// conn 已在 block 结束时释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(ProxyConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 更新所有三行的公共字段
conn.execute(
"UPDATE proxy_config SET
listen_address = ?1,
listen_port = ?2,
max_retries = ?3,
enable_logging = ?4,
streaming_first_byte_timeout = ?5,
streaming_idle_timeout = ?6,
non_streaming_timeout = ?7,
updated_at = datetime('now')",
rusqlite::params![
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
if config.enable_logging { 1 } else { 0 },
config.streaming_first_byte_timeout as i32,
config.streaming_idle_timeout as i32,
config.non_streaming_timeout as i32,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
// 不再使用此字段,由 enabled 字段替代
// 保留空实现以兼容旧代码
Ok(()) Ok(())
} }
/// 检查是否处于 Live 接管模式 /// 检查是否处于 Live 接管模式
///
/// 检查是否有任一 app 的 enabled = true
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); let conn = lock_conn!(self.conn);
let active: i32 = conn let count: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(live_takeover_active, 0) FROM proxy_config WHERE id = 1", "SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
[], [],
|row| row.get(0), |row| row.get(0),
) )
.unwrap_or(0); .map_err(|e| AppError::Database(e.to_string()))?;
Ok(active != 0) Ok(count > 0)
} }
// ==================== Provider Health ==================== // ==================== Provider Health ====================
@@ -104,6 +293,7 @@ impl Database {
provider_id: &str, provider_id: &str,
app_type: &str, app_type: &str,
) -> Result<ProviderHealth, AppError> { ) -> Result<ProviderHealth, AppError> {
let result = {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row( conn.query_row(
@@ -125,7 +315,23 @@ impl Database {
}) })
}, },
) )
.map_err(|e| AppError::Database(e.to_string())) };
match result {
Ok(health) => Ok(health),
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
provider_id: provider_id.to_string(),
app_type: app_type.to_string(),
is_healthy: true,
consecutive_failures: 0,
last_success_at: None,
last_failure_at: None,
last_error: None,
updated_at: chrono::Utc::now().to_rfc3339(),
}),
Err(e) => Err(AppError::Database(e.to_string())),
}
} }
/// 更新Provider健康状态 /// 更新Provider健康状态
@@ -230,6 +436,20 @@ impl Database {
Ok(()) Ok(())
} }
/// 清空指定应用的健康状态(关闭单个代理时使用)
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_health WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Cleared provider health records for app {app_type}");
Ok(())
}
/// 清空所有Provider健康状态(代理停止时调用) /// 清空所有Provider健康状态(代理停止时调用)
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> { pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
@@ -241,19 +461,22 @@ impl Database {
Ok(()) Ok(())
} }
// ==================== Circuit Breaker Config ==================== // ==================== Circuit Breaker Config (Legacy Compatibility) ====================
/// 获取熔断器配置 /// 获取熔断器配置(兼容旧接口,从 claude 行读取)
///
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
pub async fn get_circuit_breaker_config( pub async fn get_circuit_breaker_config(
&self, &self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> { ) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
let result = {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.query_row(
let config = conn "SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
.query_row( circuit_error_rate_threshold, circuit_min_requests
"SELECT failure_threshold, success_threshold, timeout_seconds, FROM proxy_config WHERE app_type = 'claude'",
error_rate_threshold, min_requests
FROM circuit_breaker_config WHERE id = 1",
[], [],
|row| { |row| {
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig { Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
@@ -265,27 +488,39 @@ impl Database {
}) })
}, },
) )
.map_err(|e| AppError::Database(e.to_string()))?; };
// conn 已在 block 结束时释放
Ok(config) match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,初始化默认配置
self.init_proxy_config_rows().await?;
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
}
Err(e) => Err(AppError::Database(e.to_string())),
}
} }
/// 更新熔断器配置 /// 更新熔断器配置(兼容旧接口,更新所有三行)
///
/// 熔断器配置已合并到 proxy_config 表
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
pub async fn update_circuit_breaker_config( pub async fn update_circuit_breaker_config(
&self, &self,
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig, config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 更新所有三行的熔断器配置
conn.execute( conn.execute(
"UPDATE circuit_breaker_config "UPDATE proxy_config SET
SET failure_threshold = ?1, circuit_failure_threshold = ?1,
success_threshold = ?2, circuit_success_threshold = ?2,
timeout_seconds = ?3, circuit_timeout_seconds = ?3,
error_rate_threshold = ?4, circuit_error_rate_threshold = ?4,
min_requests = ?5, circuit_min_requests = ?5,
updated_at = CURRENT_TIMESTAMP updated_at = datetime('now')",
WHERE id = 1",
rusqlite::params![ rusqlite::params![
config.failure_threshold as i32, config.failure_threshold as i32,
config.success_threshold as i32, config.success_threshold as i32,
@@ -321,6 +556,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);
+66
View File
@@ -62,4 +62,70 @@ impl Database {
Ok(()) Ok(())
} }
} }
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
/// 获取指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
/// 此方法仅用于数据库迁移时读取旧数据
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
let key = format!("proxy_takeover_{app_type}");
match self.get_setting(&key)? {
Some(value) => Ok(value == "true"),
None => Ok(false),
}
}
/// 设置指定应用的代理接管状态
///
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
)]
pub fn set_proxy_takeover_enabled(
&self,
app_type: &str,
enabled: bool,
) -> Result<(), AppError> {
let key = format!("proxy_takeover_{app_type}");
let value = if enabled { "true" } else { "false" };
self.set_setting(&key, value)
}
/// 检查是否有任一应用开启了代理接管
///
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
[],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(count > 0)
}
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false
///
/// **已废弃**: settings 表不再用于存储代理状态
#[deprecated(
since = "3.9.0",
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
)]
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::info!("已清除所有代理接管状态");
Ok(())
}
} }
+313 -228
View File
@@ -31,6 +31,7 @@ impl Database {
icon_color TEXT, icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}', meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0, is_current BOOLEAN NOT NULL DEFAULT 0,
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type) PRIMARY KEY (id, app_type)
)", )",
[], [],
@@ -54,15 +55,9 @@ impl Database {
// 3. MCP Servers 表 // 3. MCP Servers 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS mcp_servers ( "CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
name TEXT NOT NULL, description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
server_config TEXT NOT NULL, enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
description TEXT,
homepage TEXT,
docs TEXT,
tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0 enabled_gemini BOOLEAN NOT NULL DEFAULT 0
)", )",
[], [],
@@ -70,30 +65,17 @@ impl Database {
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 4. Prompts 表 // 4. Prompts 表
conn.execute( conn.execute("CREATE TABLE IF NOT EXISTS prompts (
"CREATE TABLE IF NOT EXISTS prompts ( id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
id TEXT NOT NULL, description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type) PRIMARY KEY (id, app_type)
)", )", []).map_err(|e| AppError::Database(e.to_string()))?;
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表 // 5. Skills 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS skills ( "CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL, directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
app_type TEXT NOT NULL, installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (directory, app_type)
)", )",
[], [],
) )
@@ -102,143 +84,110 @@ impl Database {
// 6. Skill Repos 表 // 6. Skill Repos 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS skill_repos ( "CREATE TABLE IF NOT EXISTS skill_repos (
owner TEXT NOT NULL, owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
name TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
branch TEXT NOT NULL DEFAULT 'main',
enabled BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (owner, name)
)", )",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 7. Settings 表 (通用配置) // 7. Settings 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS settings ( "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
key TEXT PRIMARY KEY,
value TEXT
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 8. Proxy Config 表 (代理服务器配置) // 8. Proxy Config 表(三行结构,app_type 主键)
// 代理配置表(单例) conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 初始化三行数据(每应用不同默认值)
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_config ( "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
id INTEGER PRIMARY KEY CHECK (id = 1), streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
enabled INTEGER NOT NULL DEFAULT 0, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1', circuit_error_rate_threshold, circuit_min_requests)
listen_port INTEGER NOT NULL DEFAULT 5000, VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
max_retries INTEGER NOT NULL DEFAULT 3, [],
request_timeout INTEGER NOT NULL DEFAULT 300, )
enable_logging INTEGER NOT NULL DEFAULT 1, .map_err(|e| AppError::Database(e.to_string()))?;
target_app TEXT NOT NULL DEFAULT 'claude', conn.execute(
created_at TEXT NOT NULL DEFAULT (datetime('now')), "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
)", circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 target_app 列(如果表已存在但缺少该列) // 9. Provider Health 表
// 忽略 "duplicate column name" 错误 conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
let _ = conn.execute( provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'", consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
[], last_error TEXT, updated_at TEXT NOT NULL,
);
// 9. Provider Health 表 (Provider健康状态)
conn.execute(
"CREATE TABLE IF NOT EXISTS provider_health (
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
is_healthy INTEGER NOT NULL DEFAULT 1,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_success_at TEXT,
last_failure_at TEXT,
last_error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (provider_id, app_type), PRIMARY KEY (provider_id, app_type),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)", )", []).map_err(|e| AppError::Database(e.to_string()))?;
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 10. Proxy Request Logs 表 (详细请求日志) // 10. Proxy Request Logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
)", []).map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_request_logs ( "CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider "CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
ON proxy_request_logs(provider_id, app_type)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at "CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
ON proxy_request_logs(created_at)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
conn.execute( // 11. Model Pricing 表
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
ON proxy_request_logs(model)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
ON proxy_request_logs(session_id)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
ON proxy_request_logs(status_code)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 11. Model Pricing 表 (模型定价)
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing ( "CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY, model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
display_name TEXT NOT NULL, input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)", )",
@@ -246,25 +195,13 @@ impl Database {
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 12. Stream Check Logs 表 (流式健康检查日志) // 12. Stream Check Logs 表
conn.execute( conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
"CREATE TABLE IF NOT EXISTS stream_check_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT, app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
provider_id TEXT NOT NULL, response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
provider_name TEXT NOT NULL, retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
app_type TEXT NOT NULL, )", []).map_err(|e| AppError::Database(e.to_string()))?;
status TEXT NOT NULL,
success INTEGER NOT NULL,
message TEXT NOT NULL,
response_time_ms INTEGER,
http_status INTEGER,
model_used TEXT,
retry_count INTEGER DEFAULT 0,
tested_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider "CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
@@ -273,34 +210,12 @@ impl Database {
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
// 13. Circuit Breaker Config 表 (熔断器配置) // 注意:circuit_breaker_config 已合并到 proxy_config 表中
conn.execute(
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
failure_threshold INTEGER NOT NULL DEFAULT 5,
success_threshold INTEGER NOT NULL DEFAULT 2,
timeout_seconds INTEGER NOT NULL DEFAULT 60,
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
min_requests INTEGER NOT NULL DEFAULT 10,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 插入默认熔断器配置
conn.execute(
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 16. Proxy Live Backup 表 (Live 配置备份) // 16. Proxy Live Backup 表 (Live 配置备份)
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_live_backup ( "CREATE TABLE IF NOT EXISTS proxy_live_backup (
app_type TEXT PRIMARY KEY, app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
original_config TEXT NOT NULL,
backed_up_at TEXT NOT NULL
)", )",
[], [],
) )
@@ -312,29 +227,38 @@ impl Database {
[], [],
); );
// 14. Failover Queue 表 (故障转移队列) // 尝试添加超时配置列到 proxy_config 表
conn.execute( let _ = conn.execute(
"CREATE TABLE IF NOT EXISTS failover_queue ( "ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
queue_order INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
UNIQUE (app_type, provider_id),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
[], [],
) );
.map_err(|e| AppError::Database(e.to_string()))?; let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
[],
);
// 为故障转移队列创建索引 // 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
conn.execute( Self::add_column_if_missing(
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order conn,
ON failover_queue(app_type, queue_order)", "providers",
"in_failover_queue",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 删除旧的 failover_queue 表(如果存在)
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
// 为故障转移队列创建索引(基于 providers 表)
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_providers_failover
ON providers(app_type, in_failover_queue, sort_index)",
[], [],
) );
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -472,37 +396,62 @@ impl Database {
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?; Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?; Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?; Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
Self::add_column_if_missing(
// proxy_request_logs 表(包含所有字段) conn,
conn.execute( "providers",
"CREATE TABLE IF NOT EXISTS proxy_request_logs ( "in_failover_queue",
request_id TEXT PRIMARY KEY, "BOOLEAN NOT NULL DEFAULT 0",
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
[],
)?; )?;
// 添加代理超时配置字段
if Self::table_exists(conn, "proxy_config")? {
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_first_byte_timeout",
"INTEGER NOT NULL DEFAULT 30",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 60",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"non_streaming_timeout",
"INTEGER NOT NULL DEFAULT 300",
)?;
}
// 删除旧的 failover_queue 表(如果存在)
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
// 创建 failover 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_providers_failover
ON providers(app_type, in_failover_queue, sort_index)",
[],
)
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
// proxy_request_logs 表
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
)", [])?;
// 为已存在的表添加新字段 // 为已存在的表添加新字段
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?; Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
Self::add_column_if_missing( Self::add_column_if_missing(
@@ -523,10 +472,8 @@ impl Database {
// model_pricing 表 // model_pricing 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing ( "CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY, model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
display_name TEXT NOT NULL, input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)", )",
@@ -541,6 +488,144 @@ impl Database {
// 重构 skills 表(添加 app_type 字段) // 重构 skills 表(添加 app_type 字段)
Self::migrate_skills_table(conn)?; Self::migrate_skills_table(conn)?;
// 重构 proxy_config 为三行结构(每应用独立配置)
Self::migrate_proxy_config_to_per_app(conn)?;
Ok(())
}
/// 将 proxy_config 迁移为三行结构(每应用独立配置)
fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
// 检查是否已经是新表结构(幂等性)
if !Self::table_exists(conn, "proxy_config")? {
// 表不存在,跳过迁移(新安装)
return Ok(());
}
if Self::has_column(conn, "proxy_config", "app_type")? {
// 已经是三行结构,跳过迁移
log::info!("proxy_config 已经是三行结构,跳过迁移");
return Ok(());
}
// 读取旧配置
let old_config = conn
.query_row(
"SELECT listen_address, listen_port, max_retries, enable_logging,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
FROM proxy_config WHERE id = 1",
[],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i32>(1)?,
row.get::<_, i32>(2)?,
row.get::<_, i32>(3)?,
row.get::<_, i32>(4).unwrap_or(30),
row.get::<_, i32>(5).unwrap_or(60),
row.get::<_, i32>(6).unwrap_or(300),
))
},
)
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
let old_cb = conn.query_row(
"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
FROM circuit_breaker_config WHERE id = 1", [],
|row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
).unwrap_or((5, 2, 60, 0.5, 10));
let get_bool = |key: &str| -> bool {
conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
r.get::<_, String>(0)
})
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
};
let apps = [
(
"claude",
get_bool("proxy_takeover_claude"),
get_bool("auto_failover_enabled_claude"),
6,
45,
90,
8,
3,
90,
0.6,
15,
),
(
"codex",
get_bool("proxy_takeover_codex"),
get_bool("auto_failover_enabled_codex"),
3,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
(
"gemini",
get_bool("proxy_takeover_gemini"),
get_bool("auto_failover_enabled_gemini"),
5,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
];
// 创建新表
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
// 插入三行配置
for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
conn.execute(
"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
rusqlite::params![app, old_config.0, old_config.1, old_config.3,
if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
}
// 替换表并清理
conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
conn.execute(
"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
[],
)?;
log::info!("proxy_config 已迁移为三行结构");
Ok(()) Ok(())
} }
+1
View File
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
}, },
); );
+1
View File
@@ -132,6 +132,7 @@ pub(crate) fn build_provider_from_request(
meta, meta,
icon: request.icon.clone(), icon: request.icon.clone(),
icon_color: None, icon_color: None,
in_failover_queue: false,
}; };
Ok(provider) Ok(provider)
+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()));
}
}
} }
} }
+132 -89
View File
@@ -48,8 +48,9 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc; use std::sync::Arc;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::RunEvent; use tauri::RunEvent;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -134,6 +135,19 @@ async fn update_tray_menu(
} }
} }
#[cfg(target_os = "macos")]
fn macos_tray_icon() -> Option<Image<'static>> {
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
match Image::from_bytes(ICON_BYTES) {
Ok(icon) => Some(icon),
Err(err) => {
log::warn!("Failed to load macOS tray icon: {err}");
None
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let mut builder = tauri::Builder::default(); let mut builder = tauri::Builder::default();
@@ -209,44 +223,6 @@ pub fn run() {
log::warn!("初始化 Updater 插件失败,已跳过:{e}"); log::warn!("初始化 Updater 插件失败,已跳过:{e}");
} }
} }
#[cfg(target_os = "macos")]
{
// 设置 macOS 标题栏背景色为主界面蓝色
if let Some(window) = app.get_webview_window("main") {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
match window.ns_window() {
Ok(ns_window_ptr) => {
if let Some(ns_window) =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
{
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
unsafe {
use objc2::msg_send;
let _: () =
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
}
}
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
}
}
}
// 初始化日志 // 初始化日志
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
@@ -504,12 +480,27 @@ pub fn run() {
}) })
.show_menu_on_left_click(true); .show_menu_on_left_click(true);
// 统一使用应用默认图标;待托盘模板图标就绪后再启用 // 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
#[cfg(target_os = "macos")]
{
if let Some(icon) = macos_tray_icon() {
tray_builder = tray_builder.icon(icon).icon_as_template(true);
} else if let Some(icon) = app.default_window_icon() {
log::warn!("Falling back to default window icon for tray");
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to load macOS tray icon for tray");
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(icon) = app.default_window_icon() { if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone()); tray_builder = tray_builder.icon(icon.clone());
} else { } else {
log::warn!("Failed to get default window icon for tray"); log::warn!("Failed to get default window icon for tray");
} }
}
let _tray = tray_builder.build(app)?; let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致 // 将同一个实例注入到全局状态,避免重复创建导致的不一致
@@ -525,49 +516,33 @@ 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. 自动启动代理服务器(如果配置为启用) // 检查 settings 表中的代理状态,自动恢复代理服务
match state.db.get_proxy_config().await { restore_proxy_state_on_startup(&state).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(())
@@ -605,6 +580,8 @@ pub fn run() {
commands::read_claude_plugin_config, commands::read_claude_plugin_config,
commands::apply_claude_plugin_config, commands::apply_claude_plugin_config,
commands::is_claude_plugin_applied, commands::is_claude_plugin_applied,
commands::apply_claude_onboarding_skip,
commands::clear_claude_onboarding_skip,
// Claude MCP management // Claude MCP management
commands::get_claude_mcp_status, commands::get_claude_mcp_status,
commands::read_claude_mcp_config, commands::read_claude_mcp_config,
@@ -619,7 +596,7 @@ pub fn run() {
commands::upsert_mcp_server_in_config, commands::upsert_mcp_server_in_config,
commands::delete_mcp_server_in_config, commands::delete_mcp_server_in_config,
commands::set_mcp_enabled, commands::set_mcp_enabled,
// v3.7.0: Unified MCP management // Unified MCP management
commands::get_mcp_servers, commands::get_mcp_servers,
commands::upsert_mcp_server, commands::upsert_mcp_server,
commands::delete_mcp_server, commands::delete_mcp_server,
@@ -672,11 +649,18 @@ 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_with_takeover, commands::start_proxy_server,
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,
// Global & Per-App Config
commands::get_global_proxy_config,
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::is_proxy_running, commands::is_proxy_running,
commands::is_live_takeover_active, commands::is_live_takeover_active,
commands::switch_proxy_provider, commands::switch_proxy_provider,
@@ -691,8 +675,8 @@ pub fn run() {
commands::get_available_providers_for_failover, commands::get_available_providers_for_failover,
commands::add_to_failover_queue, commands::add_to_failover_queue,
commands::remove_from_failover_queue, commands::remove_from_failover_queue,
commands::reorder_failover_queue, commands::get_auto_failover_enabled,
commands::set_failover_item_enabled, commands::set_auto_failover_enabled,
// Usage statistics // Usage statistics
commands::get_usage_summary, commands::get_usage_summary,
commands::get_usage_trends, commands::get_usage_trends,
@@ -824,32 +808,91 @@ pub fn run() {
/// ///
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。 /// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。 /// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { 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 { // 使用 keep_state 版本,保留 settings 表中的代理状态
// 接管模式:停止并恢复配置 if let Err(e) = proxy_service.stop_with_restore_keep_state().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!("代理服务器清理完成");
}
}
}
// ============================================================
// 启动时恢复代理状态
// ============================================================
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
} }
} }
log::info!("代理服务器清理完成"); if apps_to_restore.is_empty() {
log::debug!("启动时无需恢复代理状态");
return;
}
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
// 逐个恢复接管状态
for app_type in apps_to_restore {
match state
.proxy_service
.set_takeover_for_app(app_type, true)
.await
{
Ok(()) => {
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
}
Err(e) => {
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
// 失败时清除该应用的状态,避免下次启动再次尝试
if let Err(clear_err) = state
.proxy_service
.set_takeover_for_app(app_type, false)
.await
{
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
}
}
} }
} }
} }
+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()?;
+5
View File
@@ -36,6 +36,10 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")] #[serde(rename = "iconColor")]
pub icon_color: Option<String>, pub icon_color: Option<String>,
/// 是否加入故障转移队列
#[serde(default)]
#[serde(rename = "inFailoverQueue")]
pub in_failover_queue: bool,
} }
impl Provider { impl Provider {
@@ -58,6 +62,7 @@ impl Provider {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
} }
+136 -48
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,52 +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: {})",
@@ -212,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);
@@ -234,9 +246,6 @@ 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!("Circuit breaker HalfOpen probe failed, transitioning to Open"); log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
drop(config); drop(config);
@@ -307,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;
@@ -317,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);
@@ -359,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]
@@ -381,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);
// 手动转换到半开状态 // 手动转换到半开状态
@@ -390,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 {
@@ -406,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);
} }
} }
+130 -31
View File
@@ -17,6 +17,16 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::RwLock; use tokio::sync::RwLock;
pub struct ForwardResult {
pub response: Response,
pub provider: Provider,
}
pub struct ForwardError {
pub error: ProxyError,
pub provider: Option<Provider>,
}
pub struct RequestForwarder { pub struct RequestForwarder {
client: Client, client: Client,
/// 共享的 ProviderRouter(持有熔断器状态) /// 共享的 ProviderRouter(持有熔断器状态)
@@ -29,21 +39,35 @@ 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, non_streaming_timeout: u64,
max_retries: u8, max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
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,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
) -> Self { ) -> Self {
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
// 参考 Claude Code Hub 的 undici 全局超时设计
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
let mut client_builder = Client::builder(); let mut client_builder = Client::builder();
if timeout_secs > 0 { if non_streaming_timeout > 0 {
client_builder = client_builder.timeout(Duration::from_secs(timeout_secs)); // 使用配置的非流式超时
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
} else {
// 禁用超时时使用全局超时作为保底
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
} }
let client = client_builder let client = client_builder
@@ -58,6 +82,7 @@ impl RequestForwarder {
current_providers, current_providers,
failover_manager, failover_manager,
app_handle, app_handle,
current_provider_id_at_start,
} }
} }
@@ -94,10 +119,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);
} }
@@ -131,13 +154,16 @@ impl RequestForwarder {
body: Value, body: Value,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
providers: Vec<Provider>, providers: Vec<Provider>,
) -> Result<Response, ProxyError> { ) -> Result<ForwardResult, ForwardError> {
// 获取适配器 // 获取适配器
let adapter = get_adapter(app_type); let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str(); let app_type_str = app_type.as_str();
if providers.is_empty() { if providers.is_empty() {
return Err(ProxyError::NoAvailableProvider); return Err(ForwardError {
error: ProxyError::NoAvailableProvider,
provider: None,
});
} }
log::info!( log::info!(
@@ -147,17 +173,27 @@ impl RequestForwarder {
); );
let mut last_error = None; let mut last_error = None;
let mut failover_happened = false; let mut last_provider = None;
let mut attempted_providers = 0usize; let mut attempted_providers = 0usize;
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
let bypass_circuit_breaker = providers.len() == 1;
// 依次尝试每个供应商 // 依次尝试每个供应商
for provider in providers.iter() { for provider in providers.iter() {
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
if !self // 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
(true, false)
} else {
let permit = self
.router .router
.allow_provider_request(&provider.id, app_type_str) .allow_provider_request(&provider.id, app_type_str)
.await .await;
{ (permit.allowed, permit.used_half_open_permit)
};
if !allowed {
log::debug!( log::debug!(
"[{}] Provider {} 熔断器拒绝本次请求,跳过", "[{}] Provider {} 熔断器拒绝本次请求,跳过",
app_type_str, app_type_str,
@@ -167,9 +203,6 @@ impl RequestForwarder {
} }
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 +235,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 +261,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();
@@ -260,7 +301,10 @@ impl RequestForwarder {
latency latency
); );
return Ok(response); return Ok(ForwardResult {
response,
provider: provider.clone(),
});
} }
Err(e) => { Err(e) => {
let latency = start.elapsed().as_millis() as u64; let latency = start.elapsed().as_millis() as u64;
@@ -268,7 +312,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}");
@@ -295,6 +345,7 @@ impl RequestForwarder {
); );
last_error = Some(e); last_error = Some(e);
last_provider = Some(provider.clone());
// 继续尝试下一个供应商 // 继续尝试下一个供应商
continue; continue;
} }
@@ -316,7 +367,10 @@ impl RequestForwarder {
provider.name, provider.name,
e e
); );
return Err(e); return Err(ForwardError {
error: e,
provider: Some(provider.clone()),
});
} }
} }
} }
@@ -334,7 +388,10 @@ impl RequestForwarder {
(status.success_requests as f32 / status.total_requests as f32) * 100.0; (status.success_requests as f32 / status.total_requests as f32) * 100.0;
} }
} }
return Err(ProxyError::NoAvailableProvider); return Err(ForwardError {
error: ProxyError::NoAvailableProvider,
provider: None,
});
} }
// 所有供应商都失败了 // 所有供应商都失败了
@@ -354,7 +411,10 @@ impl RequestForwarder {
providers.len() providers.len()
); );
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded)) Err(ForwardError {
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
provider: last_provider,
})
} }
/// 转发单个请求(使用适配器) /// 转发单个请求(使用适配器)
@@ -370,12 +430,19 @@ impl RequestForwarder {
let base_url = adapter.extract_base_url(provider)?; let base_url = adapter.extract_base_url(provider)?;
log::info!("[{}] base_url: {}", adapter.name(), base_url); log::info!("[{}] base_url: {}", adapter.name(), base_url);
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, endpoint);
// 检查是否需要格式转换 // 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider); let needs_transform = adapter.needs_transform(provider);
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
"/v1/chat/completions"
} else {
endpoint
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
// 记录原始请求 JSON // 记录原始请求 JSON
log::info!( log::info!(
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}", "[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
@@ -383,10 +450,23 @@ impl RequestForwarder {
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string()) serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
); );
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
if let Some(ref mapped) = mapped_model {
log::info!(
"[{}] >>> 模型映射后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
);
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
}
// 转换请求体(如果需要) // 转换请求体(如果需要)
let request_body = if needs_transform { let request_body = if needs_transform {
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name()); log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
let transformed = adapter.transform_request(body.clone(), provider)?; let transformed = adapter.transform_request(mapped_body, provider)?;
log::info!( log::info!(
"[{}] >>> 转换后的请求 JSON:\n{}", "[{}] >>> 转换后的请求 JSON:\n{}",
adapter.name(), adapter.name(),
@@ -394,7 +474,7 @@ impl RequestForwarder {
); );
transformed transformed
} else { } else {
body.clone() mapped_body
}; };
log::info!( log::info!(
@@ -489,6 +569,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 {
// 网络和上游错误:都应该尝试下一个供应商 // 网络和上游错误:都应该尝试下一个供应商
@@ -499,9 +592,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,
} }
} }
+24 -4
View File
@@ -31,13 +31,26 @@ pub struct UsageParserConfig {
// 模型提取器实现 // 模型提取器实现
// ============================================================================ // ============================================================================
/// Claude 流式响应模型提取(直接使用请求模型 /// Claude 流式响应模型提取(优先使用 usage.model
fn claude_model_extractor(_events: &[Value], request_model: &str) -> String { fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
request_model.to_string() request_model.to_string()
} }
/// OpenAI Chat Completions 流式响应模型提取 /// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model
fn openai_model_extractor(events: &[Value], request_model: &str) -> String { fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从事件中直接提取
events events
.iter() .iter()
.find_map(|e| e.get("model")?.as_str()) .find_map(|e| e.get("model")?.as_str())
@@ -45,8 +58,15 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
.to_string() .to_string()
} }
/// Codex Responses API 流式响应模型提取 /// Codex Responses API 流式响应模型提取(优先使用 usage.model
fn codex_model_extractor(events: &[Value], request_model: &str) -> String { fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
}
// 回退:从 response.completed 事件中提取
events events
.iter() .iter()
.find_map(|e| { .find_map(|e| {
+44 -8
View File
@@ -5,27 +5,41 @@
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::{ use crate::proxy::{
forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError,
}; };
use std::time::Instant; use std::time::Instant;
/// 流式超时配置
#[derive(Debug, Clone, Copy)]
pub struct StreamingTimeoutConfig {
/// 首字节超时(秒),0 表示禁用
pub first_byte_timeout: u64,
/// 静默期超时(秒),0 表示禁用
pub idle_timeout: u64,
}
/// 请求上下文 /// 请求上下文
/// ///
/// 贯穿整个请求生命周期,包含: /// 贯穿整个请求生命周期,包含:
/// - 计时信息 /// - 计时信息
/// - 代理配置 /// - 应用级代理配置per-app
/// - 选中的 Provider 列表(用于故障转移) /// - 选中的 Provider 列表(用于故障转移)
/// - 请求模型名称 /// - 请求模型名称
/// - 日志标签 /// - 日志标签
pub struct RequestContext { pub struct RequestContext {
/// 请求开始时间 /// 请求开始时间
pub start_time: Instant, pub start_time: Instant,
/// 代理配置快照 /// 应用级代理配置(per-app,包含重试次数和超时配置)
pub config: ProxyConfig, pub app_config: AppProxyConfig,
/// 选中的 Provider(故障转移链的第一个) /// 选中的 Provider(故障转移链的第一个)
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"
@@ -57,7 +71,16 @@ impl RequestContext {
app_type_str: &'static str, app_type_str: &'static str,
) -> Result<Self, ProxyError> { ) -> Result<Self, ProxyError> {
let start_time = Instant::now(); let start_time = Instant::now();
let config = state.config.read().await.clone();
// 从数据库读取应用级代理配置(per-app)
let app_config = state
.db
.get_proxy_config_for_app(app_type_str)
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let current_provider_id =
crate::settings::get_current_provider(&app_type).unwrap_or_default();
// 从请求体提取模型名称 // 从请求体提取模型名称
let request_model = body let request_model = body
@@ -89,9 +112,10 @@ impl RequestContext {
Ok(Self { Ok(Self {
start_time, start_time,
config, app_config,
provider, provider,
providers, providers,
current_provider_id,
request_model, request_model,
tag, tag,
app_type_str, app_type_str,
@@ -127,12 +151,15 @@ impl RequestContext {
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
RequestForwarder::new( RequestForwarder::new(
state.provider_router.clone(), state.provider_router.clone(),
self.config.request_timeout, self.app_config.non_streaming_timeout as u64,
self.config.max_retries, self.app_config.max_retries as u8,
state.status.clone(), state.status.clone(),
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(),
self.app_config.streaming_first_byte_timeout as u64,
self.app_config.streaming_idle_timeout as u64,
) )
} }
@@ -148,4 +175,13 @@ impl RequestContext {
pub fn latency_ms(&self) -> u64 { pub fn latency_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64 self.start_time.elapsed().as_millis() as u64
} }
/// 获取流式超时配置
#[inline]
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
StreamingTimeoutConfig {
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
idle_timeout: self.app_config.streaming_idle_timeout as u64,
}
}
} }
+67 -39
View File
@@ -5,7 +5,7 @@
//! 重构后的结构: //! 重构后的结构:
//! - 通用逻辑提取到 `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}, error_mapper::{get_error_message, map_proxy_error_to_status},
@@ -54,34 +54,23 @@ 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 = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?; let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
let is_stream = body let is_stream = body
.get("stream") .get("stream")
.and_then(|s| s.as_bool()) .and_then(|s| s.as_bool())
.unwrap_or(false); .unwrap_or(false);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
// 转发请求 // 转发请求
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Claude, &AppType::Claude,
"/v1/messages", "/v1/messages",
@@ -91,13 +80,30 @@ pub async fn handle_messages(
) )
.await .await
{ {
Ok(resp) => resp, Ok(result) => result,
Err(e) => { Err(mut err) => {
log_forward_error(&state, &ctx, is_stream, &e); if let Some(provider) = err.provider.take() {
return Err(e); ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
log::info!(
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
ctx.provider.name,
needs_transform,
is_stream
);
let status = response.status(); let status = response.status();
log::info!("[Claude] 上游响应状态: {status}"); log::info!("[Claude] 上游响应状态: {status}");
@@ -112,7 +118,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,
@@ -164,10 +170,14 @@ async fn handle_claude_transform(
}) })
}; };
// 获取流式超时配置
let timeout_config = ctx.streaming_timeout_config();
let logged_stream = create_logged_passthrough_stream( let logged_stream = create_logged_passthrough_stream(
sse_stream, sse_stream,
"Claude/OpenRouter", "Claude/OpenRouter",
Some(usage_collector), Some(usage_collector),
timeout_config,
); );
let mut headers = axum::http::HeaderMap::new(); let mut headers = axum::http::HeaderMap::new();
@@ -295,7 +305,7 @@ pub async fn handle_chat_completions(
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -309,7 +319,7 @@ pub async fn handle_chat_completions(
); );
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/chat/completions", "/v1/chat/completions",
@@ -319,13 +329,19 @@ pub async fn handle_chat_completions(
) )
.await .await
{ {
Ok(resp) => resp, Ok(result) => result,
Err(e) => { Err(mut err) => {
log_forward_error(&state, &ctx, is_stream, &e); if let Some(provider) = err.provider.take() {
return Err(e); ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
@@ -337,7 +353,7 @@ pub async fn handle_responses(
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 = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -345,7 +361,7 @@ pub async fn handle_responses(
.unwrap_or(false); .unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/responses", "/v1/responses",
@@ -355,13 +371,19 @@ pub async fn handle_responses(
) )
.await .await
{ {
Ok(resp) => resp, Ok(result) => result,
Err(e) => { Err(mut err) => {
log_forward_error(&state, &ctx, is_stream, &e); if let Some(provider) = err.provider.take() {
return Err(e); ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
@@ -379,7 +401,7 @@ pub async fn handle_gemini(
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
// Gemini 的模型名称在 URI 中 // Gemini 的模型名称在 URI 中
let ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini") let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
.await? .await?
.with_model_from_uri(&uri); .with_model_from_uri(&uri);
@@ -397,7 +419,7 @@ pub async fn handle_gemini(
.unwrap_or(false); .unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = match forwarder let result = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Gemini, &AppType::Gemini,
endpoint, endpoint,
@@ -407,13 +429,19 @@ pub async fn handle_gemini(
) )
.await .await
{ {
Ok(resp) => resp, Ok(result) => result,
Err(e) => { Err(mut err) => {
log_forward_error(&state, &ctx, is_stream, &e); if let Some(provider) = err.provider.take() {
return Err(e); ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
} }
}; };
ctx.provider = result.provider;
let response = result.response;
log::info!("[Gemini] 上游响应状态: {}", response.status()); log::info!("[Gemini] 上游响应状态: {}", response.status());
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
+1
View File
@@ -11,6 +11,7 @@ pub mod handler_config;
pub mod handler_context; pub mod handler_context;
mod handlers; mod handlers;
mod health; mod health;
pub mod model_mapper;
pub mod provider_router; pub mod provider_router;
pub mod providers; pub mod providers;
pub mod response_handler; pub mod response_handler;
+264
View File
@@ -0,0 +1,264 @@
//! 模型映射模块
//!
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
use crate::provider::Provider;
use serde_json::Value;
/// 模型映射配置
pub struct ModelMapping {
pub haiku_model: Option<String>,
pub sonnet_model: Option<String>,
pub opus_model: Option<String>,
pub default_model: Option<String>,
pub reasoning_model: Option<String>,
}
impl ModelMapping {
/// 从 Provider 配置中提取模型映射
pub fn from_provider(provider: &Provider) -> Self {
let env = provider.settings_config.get("env");
Self {
haiku_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
sonnet_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
opus_model: env
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
default_model: env
.and_then(|e| e.get("ANTHROPIC_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
reasoning_model: env
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
}
}
/// 检查是否配置了任何模型映射
pub fn has_mapping(&self) -> bool {
self.haiku_model.is_some()
|| self.sonnet_model.is_some()
|| self.opus_model.is_some()
|| self.default_model.is_some()
}
/// 根据原始模型名称获取映射后的模型
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
let model_lower = original_model.to_lowercase();
// 1. thinking 模式优先使用推理模型
if has_thinking {
if let Some(ref m) = self.reasoning_model {
return m.clone();
}
}
// 2. 按模型类型匹配
if model_lower.contains("haiku") {
if let Some(ref m) = self.haiku_model {
return m.clone();
}
}
if model_lower.contains("opus") {
if let Some(ref m) = self.opus_model {
return m.clone();
}
}
if model_lower.contains("sonnet") {
if let Some(ref m) = self.sonnet_model {
return m.clone();
}
}
// 3. 默认模型
if let Some(ref m) = self.default_model {
return m.clone();
}
// 4. 无映射,保持原样
original_model.to_string()
}
}
/// 检测请求是否启用了 thinking 模式
pub fn has_thinking_enabled(body: &Value) -> bool {
body.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled")
}
/// 对请求体应用模型映射
///
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
pub fn apply_model_mapping(
mut body: Value,
provider: &Provider,
) -> (Value, Option<String>, Option<String>) {
let mapping = ModelMapping::from_provider(provider);
// 如果没有配置映射,直接返回
if !mapping.has_mapping() {
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
return (body, original, None);
}
// 提取原始模型名
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
if let Some(ref original) = original_model {
let has_thinking = has_thinking_enabled(&body);
let mapped = mapping.map_model(original, has_thinking);
if mapped != *original {
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
body["model"] = serde_json::json!(mapped);
return (body, Some(original.clone()), Some(mapped));
}
}
(body, original_model, None)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider_with_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_MODEL": "default-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
fn create_provider_without_mapping() -> Provider {
Provider {
id: "test".to_string(),
name: "Test".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_sonnet_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-sonnet-4-5-20250929"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_haiku_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-haiku-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "haiku-mapped");
assert_eq!(mapped, Some("haiku-mapped".to_string()));
}
#[test]
fn test_opus_mapping() {
let provider = create_provider_with_mapping();
let body = json!({"model": "claude-opus-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "opus-mapped");
assert_eq!(mapped, Some("opus-mapped".to_string()));
}
#[test]
fn test_thinking_mode() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "enabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "reasoning-model");
assert_eq!(mapped, Some("reasoning-model".to_string()));
}
#[test]
fn test_thinking_disabled() {
let provider = create_provider_with_mapping();
let body = json!({
"model": "claude-sonnet-4-5",
"thinking": {"type": "disabled"}
});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
#[test]
fn test_unknown_model_uses_default() {
let provider = create_provider_with_mapping();
let body = json!({"model": "some-unknown-model"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "default-model");
assert_eq!(mapped, Some("default-model".to_string()));
}
#[test]
fn test_no_mapping_configured() {
let provider = create_provider_without_mapping();
let body = json!({"model": "claude-sonnet-4-5"});
let (result, original, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "claude-sonnet-4-5");
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
assert!(mapped.is_none());
}
#[test]
fn test_case_insensitive() {
let provider = create_provider_with_mapping();
let body = json!({"model": "Claude-SONNET-4-5"});
let (result, _, mapped) = apply_model_mapping(body, &provider);
assert_eq!(result["model"], "sonnet-mapped");
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
}
}
+159 -76
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;
@@ -30,74 +30,73 @@ impl ProviderRouter {
/// 选择可用的供应商(支持故障转移) /// 选择可用的供应商(支持故障转移)
/// ///
/// 返回按优先级排序的可用供应商列表: /// 返回按优先级排序的可用供应商列表:
/// 1. 当前供应商(is_current=true)始终第一位 /// - 故障转移关闭时:仅返回当前供应商
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序) /// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
/// 3. 只返回熔断器未打开的供应商
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> { pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new(); let mut result = Vec::new();
let all_providers = self.db.get_all_providers(app_type)?;
// 1. 当前供应商始终第一位 // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
if let Some(current_id) = self.db.get_current_provider(app_type)? { let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
if let Some(current) = all_providers.get(&current_id) { Ok(config) => {
let circuit_key = format!("{}:{}", app_type, current.id); let enabled = config.auto_failover_enabled;
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
enabled
}
Err(e) => {
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
false
}
};
if breaker.is_available().await { if auto_failover_enabled {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?;
log::info!( log::info!(
"[{}] Current provider available: {} ({})", "[{}] Failover enabled, using queue order ({} items)",
app_type, app_type,
current.name, failover_providers.len()
current.id
); );
result.push(current.clone());
} else {
log::warn!(
"[{}] Current provider {} circuit breaker open, checking failover queue",
app_type,
current.name
);
}
}
}
// 2. 获取故障转移队列中的供应商 for provider in failover_providers {
let queue = self.db.get_failover_queue(app_type)?;
for item in queue {
// 跳过已添加的当前供应商
if result.iter().any(|p| p.id == item.provider_id) {
continue;
}
// 跳过禁用的队列项
if !item.enabled {
continue;
}
// 获取供应商信息
if let Some(provider) = all_providers.get(&item.provider_id) {
// 检查熔断器状态 // 检查熔断器状态
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;
if breaker.is_available().await { if breaker.is_available().await {
log::info!( log::info!(
"[{}] Failover provider available: {} ({}) at queue position {}", "[{}] Queue provider available: {} ({}) at sort_index {:?}",
app_type, app_type,
provider.name, provider.name,
provider.id, provider.id,
item.queue_order provider.sort_index
); );
result.push(provider.clone()); result.push(provider);
} else { } else {
log::debug!( log::debug!(
"[{}] Failover provider {} circuit breaker open, skipping", "[{}] Queue provider {} circuit breaker open, skipping",
app_type, app_type,
provider.name provider.name
); );
} }
} }
} else {
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
log::info!(
"[{}] Current provider: {} ({})",
app_type,
current.name,
current.id
);
result.push(current);
}
}
} }
if result.is_empty() { if result.is_empty() {
@@ -107,7 +106,7 @@ impl ProviderRouter {
} }
log::info!( log::info!(
"[{}] Failover chain: {} provider(s) available", "[{}] Provider chain: {} provider(s) available",
app_type, app_type,
result.len() result.len()
); );
@@ -123,7 +122,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
@@ -134,22 +133,30 @@ 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> {
// 1. 获取熔断器配置(用于更新健康状态和判断是否禁用) // 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
let config = self.db.get_circuit_breaker_config().await.ok(); let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5); Ok(app_config) => app_config.circuit_failure_threshold,
Err(e) => {
log::warn!(
"Failed to load circuit config for {app_type}, using default threshold: {e}"
);
5 // 默认值
}
};
// 2. 更新熔断器状态 // 2. 更新熔断器状态
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;
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,
@@ -236,12 +243,34 @@ impl ProviderRouter {
return breaker.clone(); return breaker.clone();
} }
// 从数据库加载配置 // 从 key 中提取 app_type (格式: "app_type:provider_id")
let config = self let app_type = key.split(':').next().unwrap_or("claude");
.db
.get_circuit_breaker_config() // 按应用独立读取熔断器配置
.await let config = match self.db.get_proxy_config_for_app(app_type).await {
.unwrap_or_default(); Ok(app_config) => {
log::debug!(
"Loading circuit breaker config for {key} (app={app_type}): \
failure_threshold={}, success_threshold={}, timeout={}s",
app_config.circuit_failure_threshold,
app_config.circuit_success_threshold,
app_config.circuit_timeout_seconds
);
crate::proxy::circuit_breaker::CircuitBreakerConfig {
failure_threshold: app_config.circuit_failure_threshold,
success_threshold: app_config.circuit_success_threshold,
timeout_seconds: app_config.circuit_timeout_seconds as u64,
error_rate_threshold: app_config.circuit_error_rate_threshold,
min_requests: app_config.circuit_min_requests,
}
}
Err(e) => {
log::warn!(
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
);
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
}
};
log::debug!("Creating new circuit breaker for {key} with config: {config:?}"); log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
@@ -263,25 +292,14 @@ mod tests {
let db = Arc::new(Database::memory().unwrap()); let db = Arc::new(Database::memory().unwrap());
let router = ProviderRouter::new(db); let router = ProviderRouter::new(db);
// 测试创建熔断器
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]
async fn select_providers_does_not_consume_half_open_permit() { async fn test_failover_disabled_uses_current_provider() {
let db = Arc::new(Database::memory().unwrap()); let db = Arc::new(Database::memory().unwrap());
// 配置:让熔断器 Open 后立刻进入 HalfOpentimeout_seconds=0),并用 1 次失败就打开熔断器
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
// 准备 2 个 ProviderA(当前)+ B(队列)
let provider_a = let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None); Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b = let provider_b =
@@ -293,18 +311,83 @@ mod tests {
db.add_to_failover_queue("claude", "b").unwrap(); db.add_to_failover_queue("claude", "b").unwrap();
let router = ProviderRouter::new(db.clone()); let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
// 让 B 进入 Open 状态(failure_threshold=1 assert_eq!(providers.len(), 1);
router assert_eq!(providers[0].id, "a");
.record_result("b", "claude", false, Some("fail".to_string())) }
#[tokio::test]
async fn test_failover_enabled_uses_queue_order() {
let db = Arc::new(Database::memory().unwrap());
// 设置 sort_index 来控制顺序:b=1, a=2
let mut provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
provider_a.sort_index = Some(2);
let mut provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.set_current_provider("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 按 sort_index 排序:b(1) 在前,a(2) 在后
assert_eq!(providers[0].id, "b");
assert_eq!(providers[1].id, "a");
}
#[tokio::test]
async fn test_select_providers_does_not_consume_half_open_permit() {
let db = Arc::new(Database::memory().unwrap());
db.update_circuit_breaker_config(&CircuitBreakerConfig {
failure_threshold: 1,
timeout_seconds: 0,
..Default::default()
})
.await
.unwrap();
let provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
db.add_to_failover_queue("claude", "a").unwrap();
db.add_to_failover_queue("claude", "b").unwrap();
// 启用自动故障转移(使用新的 proxy_config API
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
config.auto_failover_enabled = true;
db.update_proxy_config_for_app(config).await.unwrap();
let router = ProviderRouter::new(db.clone());
router
.record_result("b", "claude", false, false, Some("fail".to_string()))
.await .await
.unwrap(); .unwrap();
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
let providers = router.select_providers("claude").await.unwrap(); let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2); assert_eq!(providers.len(), 2);
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝) assert!(router.allow_provider_request("b", "claude").await.allowed);
assert!(router.allow_provider_request("b", "claude").await);
} }
} }
+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 配置
+72 -18
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) {
@@ -50,6 +48,24 @@ impl ClaudeAdapter {
false false
} }
/// 检测 OpenRouter 是否启用兼容模式
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
if !self.is_openrouter(provider) {
return false;
}
let raw = provider.settings_config.get("openrouter_compat_mode");
match raw {
Some(serde_json::Value::Bool(enabled)) => *enabled,
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
Some(serde_json::Value::String(value)) => {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
_ => true,
}
}
/// 检测是否为仅 Bearer 认证模式 /// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool { fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode // 检查 settings_config 中的 auth_mode
@@ -87,6 +103,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 +210,13 @@ 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。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
// Anthropic 直连
format!( format!(
"{}/{}", "{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
@@ -207,19 +232,24 @@ 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_compat_enabled(_provider)
} }
fn transform_request( fn transform_request(
@@ -253,6 +283,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
@@ -284,6 +315,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 +424,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]
@@ -398,5 +444,13 @@ mod tests {
} }
})); }));
assert!(adapter.needs_transform(&openrouter_provider)); assert!(adapter.needs_transform(&openrouter_provider));
let openrouter_disabled = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
},
"openrouter_compat_mode": false
}));
assert!(!adapter.needs_transform(&openrouter_disabled));
} }
} }
+1
View File
@@ -174,6 +174,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
+1
View File
@@ -250,6 +250,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
+9 -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,
}
} }
/// 获取默认端点 /// 获取默认端点
@@ -205,6 +209,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
@@ -215,7 +220,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]
@@ -394,6 +394,7 @@ mod tests {
meta: None, meta: None,
icon: None, icon: None,
icon_color: None, icon_color: None,
in_failover_queue: false,
} }
} }
+69 -14
View File
@@ -3,8 +3,11 @@
//! 统一处理流式和非流式 API 响应 //! 统一处理流式和非流式 API 响应
use super::{ use super::{
handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState, handler_config::UsageParserConfig,
usage::parser::TokenUsage, ProxyError, handler_context::{RequestContext, StreamingTimeoutConfig},
server::ProxyState,
usage::parser::TokenUsage,
ProxyError,
}; };
use axum::response::Response; use axum::response::Response;
use bytes::Bytes; use bytes::Bytes;
@@ -17,6 +20,7 @@ use std::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Arc,
}, },
time::Duration,
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -60,8 +64,12 @@ pub async fn handle_streaming(
// 创建使用量收集器 // 创建使用量收集器
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config); let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
// 创建带日志的透传流 // 获取流式超时配置
let logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector)); let timeout_config = ctx.streaming_timeout_config();
// 创建带日志和超时的透传流
let logged_stream =
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
let body = axum::body::Body::from_stream(logged_stream); let body = axum::body::Body::from_stream(logged_stream);
builder.body(body).unwrap() builder.body(body).unwrap()
@@ -93,12 +101,16 @@ pub async fn handle_non_streaming(
// 解析使用量 // 解析使用量
if let Some(usage) = (parser_config.response_parser)(&json_value) { if let Some(usage) = (parser_config.response_parser)(&json_value) {
let model = json_value // 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
.get("model") let model = if let Some(ref m) = usage.model {
.and_then(|m| m.as_str()) m.clone()
.unwrap_or(&ctx.request_model); } else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
m.to_string()
} else {
ctx.request_model.clone()
};
spawn_log_usage(state, ctx, usage, model, status.as_u16(), false); spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
} else { } else {
log::debug!( log::debug!(
"[{}] 未能解析 usage 信息,跳过记录", "[{}] 未能解析 usage 信息,跳过记录",
@@ -344,21 +356,60 @@ async fn log_usage_internal(
} }
} }
/// 创建带日志记录的透传流 /// 创建带日志记录和超时控制的透传流
pub fn create_logged_passthrough_stream( pub fn create_logged_passthrough_stream(
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static, stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
tag: &'static str, tag: &'static str,
usage_collector: Option<SseUsageCollector>, usage_collector: Option<SseUsageCollector>,
timeout_config: StreamingTimeoutConfig,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send { ) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! { async_stream::stream! {
let mut buffer = String::new(); let mut buffer = String::new();
let mut collector = usage_collector; let mut collector = usage_collector;
let mut is_first_chunk = true;
// 超时配置
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
Some(Duration::from_secs(timeout_config.first_byte_timeout))
} else {
None
};
let idle_timeout = if timeout_config.idle_timeout > 0 {
Some(Duration::from_secs(timeout_config.idle_timeout))
} else {
None
};
tokio::pin!(stream); tokio::pin!(stream);
while let Some(chunk) = stream.next().await { loop {
match chunk { // 选择超时时间:首字节超时或静默期超时
Ok(bytes) => { let timeout_duration = if is_first_chunk {
first_byte_timeout
} else {
idle_timeout
};
let chunk_result = match timeout_duration {
Some(duration) => {
match tokio::time::timeout(duration, stream.next()).await {
Ok(Some(chunk)) => Some(chunk),
Ok(None) => None, // 流结束
Err(_) => {
// 超时
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
break;
}
}
}
None => stream.next().await, // 无超时限制
};
match chunk_result {
Some(Ok(bytes)) => {
is_first_chunk = false;
let text = String::from_utf8_lossy(&bytes); let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text); buffer.push_str(&text);
@@ -394,11 +445,15 @@ pub fn create_logged_passthrough_stream(
yield Ok(bytes); yield Ok(bytes);
} }
Err(e) => { Some(Err(e)) => {
log::error!("[{tag}] 流错误: {e}"); log::error!("[{tag}] 流错误: {e}");
yield Err(std::io::Error::other(e.to_string())); yield Err(std::io::Error::other(e.to_string()));
break; break;
} }
None => {
// 流正常结束
break;
}
} }
} }
+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))
+77 -4
View File
@@ -3,33 +3,54 @@ 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,
/// 监听端口 /// 监听端口
pub listen_port: u16, pub listen_port: u16,
/// 最大重试次数 /// 最大重试次数
pub max_retries: u8, pub max_retries: u8,
/// 请求超时时间(秒) /// 请求超时时间(秒)- 已废弃,保留兼容
pub request_timeout: u64, pub request_timeout: u64,
/// 是否启用日志 /// 是否启用日志
pub enable_logging: bool, pub enable_logging: bool,
/// 是否正在接管 Live 配置 /// 是否正在接管 Live 配置
#[serde(default)] #[serde(default)]
pub live_takeover_active: bool, pub live_takeover_active: bool,
/// 流式首字超时(秒)- 等待首个数据块的最大时间
#[serde(default = "default_streaming_first_byte_timeout")]
pub streaming_first_byte_timeout: u64,
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
#[serde(default = "default_streaming_idle_timeout")]
pub streaming_idle_timeout: u64,
/// 非流式总超时(秒)- 非流式请求的总超时时间
#[serde(default = "default_non_streaming_timeout")]
pub non_streaming_timeout: u64,
}
fn default_streaming_first_byte_timeout() -> u64 {
30
}
fn default_streaming_idle_timeout() -> u64 {
60
}
fn default_non_streaming_timeout() -> u64 {
600
} }
impl Default for ProxyConfig { 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,
request_timeout: 300, request_timeout: 300,
enable_logging: true, enable_logging: true,
live_takeover_active: false, live_takeover_active: false,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 600,
} }
} }
} }
@@ -86,6 +107,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)]
@@ -118,3 +147,47 @@ pub struct LiveBackup {
/// 备份时间 /// 备份时间
pub backed_up_at: String, pub backed_up_at: String,
} }
/// 全局代理配置(统一字段,三行镜像)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalProxyConfig {
/// 代理总开关
pub proxy_enabled: bool,
/// 监听地址
pub listen_address: String,
/// 监听端口
pub listen_port: u16,
/// 是否启用日志
pub enable_logging: bool,
}
/// 应用级代理配置(每个 app 独立)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppProxyConfig {
/// 应用类型 (claude/codex/gemini)
pub app_type: String,
/// 该 app 代理启用开关
pub enabled: bool,
/// 该 app 自动故障转移开关
pub auto_failover_enabled: bool,
/// 最大重试次数
pub max_retries: u32,
/// 流式首字超时(秒)
pub streaming_first_byte_timeout: u32,
/// 流式静默超时(秒)
pub streaming_idle_timeout: u32,
/// 非流式总超时(秒)
pub non_streaming_timeout: u32,
/// 熔断失败阈值
pub circuit_failure_threshold: u32,
/// 熔断恢复阈值
pub circuit_success_threshold: u32,
/// 熔断恢复等待时间(秒)
pub circuit_timeout_seconds: u32,
/// 错误率阈值
pub circuit_error_rate_threshold: f64,
/// 计算错误率的最小请求数
pub circuit_min_requests: u32,
}
+87 -3
View File
@@ -34,6 +34,12 @@ impl TokenUsage {
/// 从 Claude API 非流式响应解析 /// 从 Claude API 非流式响应解析
pub fn from_claude_response(body: &Value) -> Option<Self> { pub fn from_claude_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?; let usage = body.get("usage")?;
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: usage.get("input_tokens")?.as_u64()? as u32, input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
output_tokens: usage.get("output_tokens")?.as_u64()? as u32, output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
@@ -45,7 +51,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model: None, model,
}) })
} }
@@ -53,11 +59,20 @@ impl TokenUsage {
#[allow(dead_code)] #[allow(dead_code)]
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> { pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
let mut usage = Self::default(); let mut usage = Self::default();
let mut model: Option<String> = None;
for event in events { for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
match event_type { match event_type {
"message_start" => { "message_start" => {
// 从 message_start 提取模型名称
if model.is_none() {
if let Some(message) = event.get("message") {
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
model = Some(m.to_string());
}
}
}
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) { if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
// 从 message_start 获取 input_tokens(原生 Claude API // 从 message_start 获取 input_tokens(原生 Claude API
if let Some(input) = if let Some(input) =
@@ -102,6 +117,7 @@ impl TokenUsage {
} }
if usage.input_tokens > 0 || usage.output_tokens > 0 { if usage.input_tokens > 0 || usage.output_tokens > 0 {
usage.model = model;
Some(usage) Some(usage)
} else { } else {
None None
@@ -141,6 +157,12 @@ impl TokenUsage {
return None; return None;
} }
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: input_tokens? as u32, input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32, output_tokens: output_tokens? as u32,
@@ -152,7 +174,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model: None, model,
}) })
} }
@@ -222,12 +244,18 @@ impl TokenUsage {
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self { Some(Self {
input_tokens: prompt_tokens as u32, input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32, output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens, cache_read_tokens: cached_tokens,
cache_creation_tokens: 0, cache_creation_tokens: 0,
model: None, model,
}) })
} }
@@ -322,6 +350,7 @@ mod tests {
#[test] #[test]
fn test_claude_response_parsing() { fn test_claude_response_parsing() {
let response = json!({ let response = json!({
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 100, "input_tokens": 100,
"output_tokens": 50, "output_tokens": 50,
@@ -335,10 +364,60 @@ mod tests {
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10); assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_claude_response_parsing_no_model() {
let response = json!({
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 20,
"cache_creation_input_tokens": 10
}
});
let usage = TokenUsage::from_claude_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, None);
} }
#[test] #[test]
fn test_claude_stream_parsing() { fn test_claude_stream_parsing() {
let events = vec![
json!({
"type": "message_start",
"message": {
"model": "claude-sonnet-4-20250514",
"usage": {
"input_tokens": 100,
"cache_read_input_tokens": 20,
"cache_creation_input_tokens": 10
}
}
}),
json!({
"type": "message_delta",
"usage": {
"output_tokens": 50
}
}),
];
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_claude_stream_parsing_no_model() {
let events = vec![ let events = vec![
json!({ json!({
"type": "message_start", "type": "message_start",
@@ -363,6 +442,7 @@ mod tests {
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10); assert_eq!(usage.cache_creation_tokens, 10);
assert_eq!(usage.model, None);
} }
#[test] #[test]
@@ -481,6 +561,7 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 0, "input_tokens": 0,
"output_tokens": 0 "output_tokens": 0
@@ -502,6 +583,7 @@ mod tests {
let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 150); assert_eq!(usage.input_tokens, 150);
assert_eq!(usage.output_tokens, 75); assert_eq!(usage.output_tokens, 75);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
} }
#[test] #[test]
@@ -512,6 +594,7 @@ mod tests {
json!({ json!({
"type": "message_start", "type": "message_start",
"message": { "message": {
"model": "claude-sonnet-4-20250514",
"usage": { "usage": {
"input_tokens": 200, "input_tokens": 200,
"cache_read_input_tokens": 50 "cache_read_input_tokens": 50
@@ -530,5 +613,6 @@ mod tests {
assert_eq!(usage.input_tokens, 200); assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.output_tokens, 100); assert_eq!(usage.output_tokens, 100);
assert_eq!(usage.cache_read_tokens, 50); assert_eq!(usage.cache_read_tokens, 50);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
} }
} }
+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
+48 -3
View File
@@ -3,6 +3,7 @@
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。 //! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
use futures::StreamExt; use futures::StreamExt;
use regex::Regex;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -141,15 +142,17 @@ impl StreamCheckService {
.build() .build()
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?; .map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
let model_to_test = Self::resolve_test_model(app_type, provider, config);
let result = match app_type { let result = match app_type {
AppType::Claude => { AppType::Claude => {
Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
} }
AppType::Codex => { AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
} }
AppType::Gemini => { AppType::Gemini => {
Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
} }
}; };
@@ -379,6 +382,48 @@ impl StreamCheckService {
AppError::Message(e.to_string()) AppError::Message(e.to_string())
} }
} }
fn resolve_test_model(
app_type: &AppType,
provider: &Provider,
config: &StreamCheckConfig,
) -> String {
match app_type {
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
.unwrap_or_else(|| config.claude_model.clone()),
AppType::Codex => {
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
}
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
.unwrap_or_else(|| config.gemini_model.clone()),
}
}
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
provider
.settings_config
.get("env")
.and_then(|env| env.get(key))
.and_then(|value| value.as_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn extract_codex_model(provider: &Provider) -> Option<String> {
let config_text = provider
.settings_config
.get("config")
.and_then(|value| value.as_str())?;
if config_text.trim().is_empty() {
return None;
}
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
re.captures(config_text)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty())
}
} }
#[cfg(test)] #[cfg(test)]
+8
View File
@@ -31,6 +31,9 @@ pub struct AppSettings {
/// 是否启用 Claude 插件联动 /// 是否启用 Claude 插件联动
#[serde(default)] #[serde(default)]
pub enable_claude_plugin_integration: bool, pub enable_claude_plugin_integration: bool,
/// 是否跳过 Claude Code 初次安装确认
#[serde(default = "default_true")]
pub skip_claude_onboarding: bool,
/// 是否开机自启 /// 是否开机自启
#[serde(default)] #[serde(default)]
pub launch_on_startup: bool, pub launch_on_startup: bool,
@@ -65,12 +68,17 @@ fn default_minimize_to_tray_on_close() -> bool {
true true
} }
fn default_true() -> bool {
true
}
impl Default for AppSettings { impl Default for AppSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
show_in_tray: true, show_in_tray: true,
minimize_to_tray_on_close: true, minimize_to_tray_on_close: true,
enable_claude_plugin_integration: false, enable_claude_plugin_integration: false,
skip_claude_onboarding: true,
launch_on_startup: false, launch_on_startup: false,
language: None, language: None,
claude_config_dir: None, claude_config_dir: None,
+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"
);
}
+130 -44
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState, useRef } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
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,
@@ -45,8 +47,13 @@ import { Button } from "@/components/ui/button";
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
const DRAG_BAR_HEIGHT = 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
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");
@@ -58,6 +65,22 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]); const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false); const [showEnvBanner, setShowEnvBanner] = useState(false);
// 保存最后一个有效的 provider,用于动画退出期间显示内容
const lastUsageProviderRef = useRef<Provider | null>(null);
const lastEditingProviderRef = useRef<Provider | null>(null);
useEffect(() => {
if (usageProvider) {
lastUsageProviderRef.current = usageProvider;
}
}, [usageProvider]);
useEffect(() => {
if (editingProvider) {
lastEditingProviderRef.current = editingProvider;
}
}, [editingProvider]);
const promptPanelRef = useRef<any>(null); const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null); const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null); const skillsPageRef = useRef<any>(null);
@@ -65,7 +88,20 @@ 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,
status: proxyStatus,
} = useProxyStatus();
// 当前应用的代理是否开启
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
const activeProviderId = useMemo(() => {
const target = proxyStatus?.active_targets?.find(
(t) => t.app_type === activeApp,
);
return target?.provider_id;
}, [proxyStatus?.active_targets, activeApp]);
// 获取供应商列表,当代理服务运行时自动刷新 // 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, { const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
@@ -268,7 +304,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) {
@@ -277,6 +326,7 @@ function App() {
}; };
const renderContent = () => { const renderContent = () => {
const content = (() => {
switch (currentView) { switch (currentView) {
case "settings": case "settings":
return ( return (
@@ -311,20 +361,33 @@ function App() {
/> />
); );
case "agents": case "agents":
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />; return (
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
);
default: default:
return ( return (
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden"> <div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */} {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1"> <div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<div className="space-y-4"> <AnimatePresence mode="wait">
<motion.div
key={activeApp}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="space-y-4"
>
<ProviderList <ProviderList
providers={providers} providers={providers}
currentProviderId={currentProviderId} currentProviderId={currentProviderId}
appId={activeApp} appId={activeApp}
isLoading={isLoading} isLoading={isLoading}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyRunning && isTakeoverActive} isProxyTakeover={
isProxyRunning && isCurrentAppTakeoverActive
}
activeProviderId={activeProviderId}
onSwitch={switchProvider} onSwitch={switchProvider}
onEdit={setEditingProvider} onEdit={setEditingProvider}
onDelete={setConfirmDelete} onDelete={setConfirmDelete}
@@ -333,23 +396,39 @@ function App() {
onOpenWebsite={handleOpenWebsite} onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)} onCreate={() => setIsAddOpen(true)}
/> />
</div> </motion.div>
</AnimatePresence>
</div> </div>
</div> </div>
); );
} }
})();
return (
<AnimatePresence mode="wait">
<motion.div
key={currentView}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{content}
</motion.div>
</AnimatePresence>
);
}; };
return ( return (
<div <div
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30" className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden" }} style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
> >
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */} {/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
<div <div
className="fixed top-0 left-0 right-0 h-4 z-[60]" className="fixed top-0 left-0 right-0 z-[60]"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any} style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/> />
{/* 环境变量警告横幅 */} {/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && ( {showEnvBanner && envConflicts.length > 0 && (
@@ -379,13 +458,18 @@ function App() {
)} )}
<header <header
className="fixed top-0 z-50 w-full py-3 bg-background/80 backdrop-blur-md transition-all duration-300" className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any} style={
{
WebkitAppRegion: "drag",
top: DRAG_BAR_HEIGHT,
height: HEADER_HEIGHT,
} as any
}
> >
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
<div <div
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2" className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any} style={{ WebkitAppRegion: "drag" } as any}
> >
@@ -401,7 +485,7 @@ function App() {
onClick={() => setCurrentView("providers")} onClick={() => setCurrentView("providers")}
className="mr-2 rounded-lg" className="mr-2 rounded-lg"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
{currentView === "settings" && t("settings.title")} {currentView === "settings" && t("settings.title")}
@@ -421,7 +505,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",
)} )}
@@ -435,7 +519,7 @@ function App() {
title={t("common.settings")} title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<Settings className="h-4 w-4" /> <Settings className="w-4 h-4" />
</Button> </Button>
</div> </div>
<UpdateBadge onClick={() => setCurrentView("settings")} /> <UpdateBadge onClick={() => setCurrentView("settings")} />
@@ -444,27 +528,27 @@ function App() {
</div> </div>
<div <div
className="flex items-center gap-2" className="flex items-center gap-2 h-[32px]"
style={{ WebkitAppRegion: "no-drag" } as any} style={{ WebkitAppRegion: "no-drag" } as any}
> >
{currentView === "prompts" && ( {currentView === "prompts" && (
<Button <Button
size="icon" size="icon"
onClick={() => promptPanelRef.current?.openAdd()} onClick={() => promptPanelRef.current?.openAdd()}
className={addActionButtonClass} className={`ml-auto ${addActionButtonClass}`}
title={t("prompts.add")} title={t("prompts.add")}
> >
<Plus className="h-5 w-5" /> <Plus className="w-5 h-5" />
</Button> </Button>
)} )}
{currentView === "mcp" && ( {currentView === "mcp" && (
<Button <Button
size="icon" size="icon"
onClick={() => mcpPanelRef.current?.openAdd()} onClick={() => mcpPanelRef.current?.openAdd()}
className={addActionButtonClass} className={`ml-auto ${addActionButtonClass}`}
title={t("mcp.unifiedPanel.addServer")} title={t("mcp.unifiedPanel.addServer")}
> >
<Plus className="h-5 w-5" /> <Plus className="w-5 h-5" />
</Button> </Button>
)} )}
{currentView === "skills" && ( {currentView === "skills" && (
@@ -475,7 +559,7 @@ function App() {
onClick={() => skillsPageRef.current?.refresh()} onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{t("skills.refresh")} {t("skills.refresh")}
</Button> </Button>
<Button <Button
@@ -484,29 +568,33 @@ function App() {
onClick={() => skillsPageRef.current?.openRepoManager()} onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5" className="hover:bg-black/5 dark:hover:bg-white/5"
> >
<Settings className="h-4 w-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
{t("skills.repoManager")} {t("skills.repoManager")}
</Button> </Button>
</> </>
)} )}
{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="flex items-center gap-1 p-1 bg-muted rounded-xl">
{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="flex-shrink-0 w-4 h-4" />
</Button> </Button>
)}
{/* TODO: Agents 功能开发中,暂时隐藏入口 */} {/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && ( {/* {isClaudeApp && (
<Button <Button
@@ -516,7 +604,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents" title="Agents"
> >
<Bot className="h-4 w-4" /> <Bot className="w-4 h-4" />
</Button> </Button>
)} */} )} */}
<Button <Button
@@ -526,7 +614,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")} title={t("prompts.manage")}
> >
<Book className="h-4 w-4" /> <Book className="w-4 h-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -535,7 +623,7 @@ function App() {
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5" className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("mcp.title")} title={t("mcp.title")}
> >
<Server className="h-4 w-4" /> <Server className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -544,7 +632,7 @@ function App() {
size="icon" size="icon"
className={`ml-2 ${addActionButtonClass}`} className={`ml-2 ${addActionButtonClass}`}
> >
<Plus className="h-5 w-5" /> <Plus className="w-5 h-5" />
</Button> </Button>
</> </>
)} )}
@@ -552,13 +640,8 @@ function App() {
</div> </div>
</header> </header>
<main <main className="flex-1 pb-12 animate-fade-in ">
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${ <div className="pb-12">{renderContent()}</div>
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
</main> </main>
<AddProviderDialog <AddProviderDialog
@@ -570,7 +653,7 @@ function App() {
<EditProviderDialog <EditProviderDialog
open={Boolean(editingProvider)} open={Boolean(editingProvider)}
provider={editingProvider} provider={lastEditingProviderRef.current}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setEditingProvider(null); setEditingProvider(null);
@@ -578,16 +661,19 @@ function App() {
}} }}
onSubmit={handleEditProvider} onSubmit={handleEditProvider}
appId={activeApp} appId={activeApp}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
/> />
{usageProvider && ( {lastUsageProviderRef.current && (
<UsageScriptModal <UsageScriptModal
provider={usageProvider} provider={lastUsageProviderRef.current}
appId={activeApp} appId={activeApp}
isOpen={Boolean(usageProvider)} isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)} onClose={() => setUsageProvider(null)}
onSave={(script) => { onSave={(script) => {
if (usageProvider) {
void saveUsageScript(usageProvider, script); void saveUsageScript(usageProvider, script);
}
}} }}
/> />
)} )}
+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"
+1 -1
View File
@@ -56,7 +56,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
" "
aria-label={t("common.close")} aria-label={t("common.close")}
> >
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" /> <X className="w-3 h-3 text-muted-foreground" />
</button> </button>
</div> </div>
); );
+3 -3
View File
@@ -114,7 +114,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
{/* 第一行:更新时间和刷新按钮 */} {/* 第一行:更新时间和刷新按钮 */}
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
{/* 上次查询时间 */} {/* 上次查询时间 */}
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1"> <span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} /> <Clock size={10} />
{lastQueriedAt {lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t) ? formatRelativeTime(lastQueriedAt, now, t)
@@ -128,7 +128,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
refetch(); refetch();
}} }}
disabled={loading} disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500" className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
title={t("usage.refreshUsage")} title={t("usage.refreshUsage")}
> >
<RefreshCw size={12} className={loading ? "animate-spin" : ""} /> <RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -191,7 +191,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 自动查询时间提示 */} {/* 自动查询时间提示 */}
{lastQueriedAt && ( {lastQueriedAt && (
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1"> <span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
<Clock size={10} /> <Clock size={10} />
{formatRelativeTime(lastQueriedAt, now, t)} {formatRelativeTime(lastQueriedAt, now, t)}
</span> </span>
+17 -5
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -32,10 +33,14 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
}; };
}, [isOpen]); }, [isOpen]);
if (!isOpen) return null;
return createPortal( return createPortal(
<div <AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[60] flex flex-col" className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }} style={{ backgroundColor: "hsl(var(--background))" }}
> >
@@ -46,7 +51,12 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
> >
<div className="h-4 w-full" data-tauri-drag-region /> <div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4"> <div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}> <Button
type="button"
variant="outline"
size="icon"
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2> <h2 className="text-lg font-semibold text-foreground">{title}</h2>
@@ -71,7 +81,9 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
</div> </div>
</div> </div>
)} )}
</div>, </motion.div>
)}
</AnimatePresence>,
document.body, document.body,
); );
}; };
+1 -1
View File
@@ -198,7 +198,7 @@ export function EnvWarningBanner({
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all"> <p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
{t("env.field.value")}: {conflict.varValue} {t("env.field.value")}: {conflict.varValue}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1"> <p className="text-xs text-muted-foreground mt-1">
{t("env.field.source")}:{" "} {t("env.field.source")}:{" "}
{getSourceDescription(conflict)} {getSourceDescription(conflict)}
</p> </p>
+11 -13
View File
@@ -129,18 +129,18 @@ const UnifiedMcpPanel = React.forwardRef<
{/* Content - Scrollable */} {/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24"> <div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? ( {isLoading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400"> <div className="text-center py-12 text-muted-foreground">
{t("mcp.loading")} {t("mcp.loading")}
</div> </div>
) : serverEntries.length === 0 ? ( ) : serverEntries.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<Server size={24} className="text-gray-400 dark:text-gray-500" /> <Server size={24} className="text-muted-foreground" />
</div> </div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
{t("mcp.unifiedPanel.noServers")} {t("mcp.unifiedPanel.noServers")}
</h3> </h3>
<p className="text-gray-500 dark:text-gray-400 text-sm"> <p className="text-muted-foreground text-sm">
{t("mcp.emptyDescription")} {t("mcp.emptyDescription")}
</p> </p>
</div> </div>
@@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
{/* 左侧:服务器信息 */} {/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100"> <h3 className="font-medium text-foreground">{name}</h3>
{name}
</h3>
{docsUrl && ( {docsUrl && (
<Button <Button
type="button" type="button"
@@ -253,12 +251,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
)} )}
</div> </div>
{description && ( {description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2"> <p className="text-sm text-muted-foreground line-clamp-2">
{description} {description}
</p> </p>
)} )}
{!description && tags && tags.length > 0 && ( {!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate"> <p className="text-xs text-muted-foreground/70 truncate">
{tags.join(", ")} {tags.join(", ")}
</p> </p>
)} )}
@@ -269,7 +267,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label <label
htmlFor={`${id}-claude`} htmlFor={`${id}-claude`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer" className="text-sm text-foreground/80 cursor-pointer"
> >
{t("mcp.unifiedPanel.apps.claude")} {t("mcp.unifiedPanel.apps.claude")}
</label> </label>
@@ -285,7 +283,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label <label
htmlFor={`${id}-codex`} htmlFor={`${id}-codex`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer" className="text-sm text-foreground/80 cursor-pointer"
> >
{t("mcp.unifiedPanel.apps.codex")} {t("mcp.unifiedPanel.apps.codex")}
</label> </label>
@@ -301,7 +299,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label <label
htmlFor={`${id}-gemini`} htmlFor={`${id}-gemini`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer" className="text-sm text-foreground/80 cursor-pointer"
> >
{t("mcp.unifiedPanel.apps.gemini")} {t("mcp.unifiedPanel.apps.gemini")}
</label> </label>
+2 -4
View File
@@ -36,11 +36,9 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1"> <h3 className="font-medium text-foreground mb-1">{prompt.name}</h3>
{prompt.name}
</h3>
{prompt.description && ( {prompt.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 truncate"> <p className="text-sm text-muted-foreground truncate">
{prompt.description} {prompt.description}
</p> </p>
)} )}
+5 -8
View File
@@ -108,21 +108,18 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
<div className="flex-1 overflow-y-auto pb-16"> <div className="flex-1 overflow-y-auto pb-16">
{loading ? ( {loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400"> <div className="text-center py-12 text-muted-foreground">
{t("prompts.loading")} {t("prompts.loading")}
</div> </div>
) : promptEntries.length === 0 ? ( ) : promptEntries.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<FileText <FileText size={24} className="text-muted-foreground" />
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div> </div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
{t("prompts.empty")} {t("prompts.empty")}
</h3> </h3>
<p className="text-gray-500 dark:text-gray-400 text-sm"> <p className="text-muted-foreground text-sm">
{t("prompts.emptyDescription")} {t("prompts.emptyDescription")}
</p> </p>
</div> </div>
@@ -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<
@@ -0,0 +1,34 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface FailoverPriorityBadgeProps {
priority: number; // 1, 2, 3, ...
className?: string;
}
/**
*
*
*/
export function FailoverPriorityBadge({
priority,
className,
}: FailoverPriorityBadgeProps) {
const { t } = useTranslation();
return (
<div
className={cn(
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold",
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
className,
)}
title={t("failover.priority.tooltip", {
priority,
defaultValue: `故障转移优先级 ${priority}`,
})}
>
P{priority}
</div>
);
}
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { HealthStatus } from "@/lib/api/model-test"; import type { HealthStatus } from "@/lib/api/model-test";
import { useTranslation } from "react-i18next";
interface HealthStatusIndicatorProps { interface HealthStatusIndicatorProps {
status: HealthStatus; status: HealthStatus;
@@ -11,17 +12,20 @@ interface HealthStatusIndicatorProps {
const statusConfig = { const statusConfig = {
operational: { operational: {
color: "bg-emerald-500", color: "bg-emerald-500",
label: "正常", labelKey: "health.operational",
labelFallback: "正常",
textColor: "text-emerald-600 dark:text-emerald-400", textColor: "text-emerald-600 dark:text-emerald-400",
}, },
degraded: { degraded: {
color: "bg-yellow-500", color: "bg-yellow-500",
label: "降级", labelKey: "health.degraded",
labelFallback: "降级",
textColor: "text-yellow-600 dark:text-yellow-400", textColor: "text-yellow-600 dark:text-yellow-400",
}, },
failed: { failed: {
color: "bg-red-500", color: "bg-red-500",
label: "失败", labelKey: "health.failed",
labelFallback: "失败",
textColor: "text-red-600 dark:text-red-400", textColor: "text-red-600 dark:text-red-400",
}, },
}; };
@@ -31,13 +35,15 @@ export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
responseTimeMs, responseTimeMs,
className, className,
}) => { }) => {
const { t } = useTranslation();
const config = statusConfig[status]; const config = statusConfig[status];
const label = t(config.labelKey, { defaultValue: config.labelFallback });
return ( return (
<div className={cn("flex items-center gap-2", className)}> <div className={cn("flex items-center gap-2", className)}>
<div className={cn("w-2 h-2 rounded-full", config.color)} /> <div className={cn("w-2 h-2 rounded-full", config.color)} />
<span className={cn("text-xs font-medium", config.textColor)}> <span className={cn("text-xs font-medium", config.textColor)}>
{config.label} {label}
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`} {responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
</span> </span>
</div> </div>
+78 -23
View File
@@ -5,6 +5,7 @@ import {
Edit, Edit,
Loader2, Loader2,
Play, Play,
Plus,
TestTube2, TestTube2,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -22,6 +23,10 @@ interface ProviderActionsProps {
onTest?: () => void; onTest?: () => void;
onConfigureUsage: () => void; onConfigureUsage: () => void;
onDelete: () => void; onDelete: () => void;
// 故障转移相关
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
} }
export function ProviderActions({ export function ProviderActions({
@@ -34,38 +39,88 @@ export function ProviderActions({
onTest, onTest,
onConfigureUsage, onConfigureUsage,
onDelete, onDelete,
// 故障转移相关
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
}: ProviderActionsProps) { }: ProviderActionsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1"; const iconButtonClass = "h-8 w-8 p-1";
// 故障转移模式下的按钮逻辑
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
// 处理主按钮点击
const handleMainButtonClick = () => {
if (isFailoverMode) {
// 故障转移模式:切换队列状态
onToggleFailover(!isInFailoverQueue);
} else {
// 普通模式:切换供应商
onSwitch();
}
};
// 主按钮的状态和样式
const getMainButtonState = () => {
if (isFailoverMode) {
// 故障转移模式
if (isInFailoverQueue) {
return {
disabled: false,
variant: "secondary" as const,
className:
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
icon: <Check className="h-4 w-4" />,
text: t("failover.inQueue", { defaultValue: "已加入" }),
};
}
return {
disabled: false,
variant: "default" as const,
className:
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
icon: <Plus className="h-4 w-4" />,
text: t("failover.addQueue", { defaultValue: "加入" }),
};
}
// 普通模式
if (isCurrent) {
return {
disabled: true,
variant: "secondary" as const,
className:
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
icon: <Check className="h-4 w-4" />,
text: t("provider.inUse"),
};
}
return {
disabled: false,
variant: "default" as const,
className: isProxyTakeover
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
: "",
icon: <Play className="h-4 w-4" />,
text: t("provider.enable"),
};
};
const buttonState = getMainButtonState();
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Button <Button
size="sm" size="sm"
variant={isCurrent ? "secondary" : "default"} variant={buttonState.variant}
onClick={onSwitch} onClick={handleMainButtonClick}
disabled={isCurrent} disabled={buttonState.disabled}
className={cn( className={cn("w-[4.5rem] px-2.5", buttonState.className)}
"w-[4.5rem] px-2.5",
isCurrent &&
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
// 代理接管模式下启用按钮使用绿色
!isCurrent &&
isProxyTakeover &&
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
)}
> >
{isCurrent ? ( {buttonState.icon}
<> {buttonState.text}
<Check className="h-4 w-4" />
{t("provider.inUse")}
</>
) : (
<>
<Play className="h-4 w-4" />
{t("provider.enable")}
</>
)}
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
+50 -15
View File
@@ -12,6 +12,7 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon"; import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter"; import UsageFooter from "@/components/UsageFooter";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { useProviderHealth } from "@/lib/query/failover"; import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries"; import { useUsageQuery } from "@/lib/query/queries";
@@ -36,6 +37,12 @@ interface ProviderCardProps {
isProxyRunning: boolean; isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换) isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
dragHandleProps?: DragHandleProps; dragHandleProps?: DragHandleProps;
// 故障转移相关
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...
isInFailoverQueue?: boolean; // 是否在故障转移队列中
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
} }
const extractApiUrl = (provider: Provider, fallbackText: string) => { const extractApiUrl = (provider: Provider, fallbackText: string) => {
@@ -88,6 +95,12 @@ export function ProviderCard({
isProxyRunning, isProxyRunning,
isProxyTakeover = false, isProxyTakeover = false,
dragHandleProps, dragHandleProps,
// 故障转移相关
isAutoFailoverEnabled = false,
failoverPriority,
isInFailoverQueue = false,
onToggleFailover,
activeProviderId,
}: ProviderCardProps) { }: ProviderCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -148,21 +161,32 @@ export function ProviderCard({
onOpenWebsite(displayUrl); onOpenWebsite(displayUrl);
}; };
// 判断是否是"当前使用中"的供应商
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 代理接管模式(非故障转移):isCurrent
// - 普通模式:isCurrent
const isActiveProvider = isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
const shouldUseGreen = isProxyTakeover && isActiveProvider;
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
return ( return (
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300", "relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"bg-card text-card-foreground group", "bg-card text-card-foreground group",
// 代理接管模式下 hover 使用绿色边框,否则使用蓝色 // hover 时的边框效果
isProxyTakeover isAutoFailoverEnabled || isProxyTakeover
? "hover:border-emerald-500/50" ? "hover:border-emerald-500/50"
: "hover:border-border-active", : "hover:border-border-active",
// 代理接管模式下当前供应商使用绿色边框 // 当前激活的供应商边框样式
isProxyTakeover && isCurrent shouldUseGreen &&
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10" "border-emerald-500/60 shadow-sm shadow-emerald-500/10",
: isCurrent shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
? "border-primary/50 shadow-sm" !isActiveProvider && "hover:shadow-sm",
: "hover:shadow-sm",
dragHandleProps?.isDragging && dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10", "cursor-grabbing border-primary shadow-lg scale-105 z-10",
)} )}
@@ -170,11 +194,11 @@ export function ProviderCard({
<div <div
className={cn( className={cn(
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none", "absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
// 代理接管模式使用绿色渐变,否则使用蓝色主色调 // 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
isProxyTakeover && isCurrent shouldUseGreen && "from-emerald-500/10",
? "from-emerald-500/10" shouldUseBlue && "from-blue-500/10",
: "from-primary/10", !isActiveProvider && "from-primary/10",
isCurrent ? "opacity-100" : "opacity-0", isActiveProvider ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -209,13 +233,20 @@ export function ProviderCard({
{provider.name} {provider.name}
</h3> </h3>
{/* 健康状态徽章和优先级 */} {/* 健康状态徽章 */}
{isProxyRunning && health && ( {isProxyRunning && isInFailoverQueue && health && (
<ProviderHealthBadge <ProviderHealthBadge
consecutiveFailures={health.consecutive_failures} consecutiveFailures={health.consecutive_failures}
/> />
)} )}
{/* 故障转移优先级徽章 */}
{isAutoFailoverEnabled &&
isInFailoverQueue &&
failoverPriority && (
<FailoverPriorityBadge priority={failoverPriority} />
)}
{provider.category === "third_party" && {provider.category === "third_party" &&
provider.meta?.isPartner && ( provider.meta?.isPartner && (
<span <span
@@ -308,6 +339,10 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined} onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)} onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)} onDelete={() => onDelete(provider)}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
/> />
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ProviderHealthStatus } from "@/types/proxy"; import { ProviderHealthStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
interface ProviderHealthBadgeProps { interface ProviderHealthBadgeProps {
consecutiveFailures: number; consecutiveFailures: number;
@@ -14,11 +15,14 @@ export function ProviderHealthBadge({
consecutiveFailures, consecutiveFailures,
className, className,
}: ProviderHealthBadgeProps) { }: ProviderHealthBadgeProps) {
const { t } = useTranslation();
// 根据失败次数计算状态 // 根据失败次数计算状态
const getStatus = () => { const getStatus = () => {
if (consecutiveFailures === 0) { if (consecutiveFailures === 0) {
return { return {
label: "正常", labelKey: "health.operational",
labelFallback: "正常",
status: ProviderHealthStatus.Healthy, status: ProviderHealthStatus.Healthy,
color: "bg-green-500", color: "bg-green-500",
// 使用更深/柔和的背景色,去除可能的白色内容感 // 使用更深/柔和的背景色,去除可能的白色内容感
@@ -27,7 +31,8 @@ export function ProviderHealthBadge({
}; };
} else if (consecutiveFailures < 5) { } else if (consecutiveFailures < 5) {
return { return {
label: "降级", labelKey: "health.degraded",
labelFallback: "降级",
status: ProviderHealthStatus.Degraded, status: ProviderHealthStatus.Degraded,
color: "bg-yellow-500", color: "bg-yellow-500",
bgColor: "bg-yellow-500/10", bgColor: "bg-yellow-500/10",
@@ -35,7 +40,8 @@ export function ProviderHealthBadge({
}; };
} else { } else {
return { return {
label: "熔断", labelKey: "health.circuitOpen",
labelFallback: "熔断",
status: ProviderHealthStatus.Failed, status: ProviderHealthStatus.Failed,
color: "bg-red-500", color: "bg-red-500",
bgColor: "bg-red-500/10", bgColor: "bg-red-500/10",
@@ -45,6 +51,9 @@ export function ProviderHealthBadge({
}; };
const statusConfig = getStatus(); const statusConfig = getStatus();
const label = t(statusConfig.labelKey, {
defaultValue: statusConfig.labelFallback,
});
return ( return (
<div <div
@@ -54,10 +63,13 @@ export function ProviderHealthBadge({
statusConfig.textColor, statusConfig.textColor,
className, className,
)} )}
title={`连续失败 ${consecutiveFailures}`} title={t("health.consecutiveFailures", {
count: consecutiveFailures,
defaultValue: `连续失败 ${consecutiveFailures}`,
})}
> >
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} /> <div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
<span>{statusConfig.label}</span> <span>{label}</span>
</div> </div>
); );
} }
+218 -11
View File
@@ -5,13 +5,31 @@ import {
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import type { CSSProperties } from "react"; import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Provider } from "@/types"; import type { Provider } from "@/types";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort"; import { useDragSort } from "@/hooks/useDragSort";
import { useStreamCheck } from "@/hooks/useStreamCheck"; import { useStreamCheck } from "@/hooks/useStreamCheck";
import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
import {
useAutoFailoverEnabled,
useFailoverQueue,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
} from "@/lib/query/failover";
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface ProviderListProps { interface ProviderListProps {
providers: Record<string, Provider>; providers: Record<string, Provider>;
@@ -27,6 +45,7 @@ interface ProviderListProps {
isLoading?: boolean; isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态 isProxyRunning?: boolean; // 代理服务运行状态
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管) isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
} }
export function ProviderList({ export function ProviderList({
@@ -41,9 +60,11 @@ export function ProviderList({
onOpenWebsite, onOpenWebsite,
onCreate, onCreate,
isLoading = false, isLoading = false,
isProxyRunning = false, // 默认值为 false isProxyRunning = false,
isProxyTakeover = false, // 默认值为 false isProxyTakeover = false,
activeProviderId,
}: ProviderListProps) { }: ProviderListProps) {
const { t } = useTranslation();
const { sortedProviders, sensors, handleDragEnd } = useDragSort( const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers, providers,
appId, appId,
@@ -52,17 +73,103 @@ export function ProviderList({
// 流式健康检查 // 流式健康检查
const { checkProvider, isChecking } = useStreamCheck(appId); const { checkProvider, isChecking } = useStreamCheck(appId);
// 故障转移相关
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
const isFailoverModeActive =
isProxyTakeover === true && isAutoFailoverEnabled === true;
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
if (!isFailoverModeActive || !failoverQueue) return undefined;
const index = failoverQueue.findIndex(
(item) => item.providerId === providerId,
);
return index >= 0 ? index + 1 : undefined;
},
[isFailoverModeActive, failoverQueue],
);
// 判断供应商是否在故障转移队列中
const isInFailoverQueue = useCallback(
(providerId: string): boolean => {
if (!isFailoverModeActive || !failoverQueue) return false;
return failoverQueue.some((item) => item.providerId === providerId);
},
[isFailoverModeActive, failoverQueue],
);
// 切换供应商的故障转移队列状态
const handleToggleFailover = useCallback(
(providerId: string, enabled: boolean) => {
if (enabled) {
addToQueue.mutate({ appType: appId, providerId });
} else {
removeFromQueue.mutate({ appType: appId, providerId });
}
},
[appId, addToQueue, removeFromQueue],
);
const handleTest = (provider: Provider) => { const handleTest = (provider: Provider) => {
checkProvider(provider.id, provider.name); checkProvider(provider.id, provider.name);
}; };
const [searchTerm, setSearchTerm] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if ((event.metaKey || event.ctrlKey) && key === "f") {
event.preventDefault();
setIsSearchOpen(true);
return;
}
if (key === "escape") {
setIsSearchOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
if (isSearchOpen) {
const frame = requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}
}, [isSearchOpen]);
const filteredProviders = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase();
if (!keyword) return sortedProviders;
return sortedProviders.filter((provider) => {
const fields = [provider.name, provider.notes, provider.websiteUrl];
return fields.some((field) =>
field?.toString().toLowerCase().includes(keyword),
);
});
}, [searchTerm, sortedProviders]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{[0, 1, 2].map((index) => ( {[0, 1, 2].map((index) => (
<div <div
key={index} key={index}
className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40" className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40"
/> />
))} ))}
</div> </div>
@@ -73,21 +180,18 @@ export function ProviderList({
return <ProviderEmptyState onCreate={onCreate} />; return <ProviderEmptyState onCreate={onCreate} />;
} }
return ( const renderProviderList = () => (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={sortedProviders.map((provider) => provider.id)} items={filteredProviders.map((provider) => provider.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div <div className="space-y-3">
className="space-y-3 animate-slide-up" {filteredProviders.map((provider) => (
style={{ animationDelay: "0.1s" }}
>
{sortedProviders.map((provider) => (
<SortableProviderCard <SortableProviderCard
key={provider.id} key={provider.id}
provider={provider} provider={provider}
@@ -103,12 +207,98 @@ export function ProviderList({
isTesting={isChecking(provider.id)} isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
isProxyTakeover={isProxyTakeover} isProxyTakeover={isProxyTakeover}
// 故障转移相关:联动状态
isAutoFailoverEnabled={isFailoverModeActive}
failoverPriority={getFailoverPriority(provider.id)}
isInFailoverQueue={isInFailoverQueue(provider.id)}
onToggleFailover={(enabled) =>
handleToggleFailover(provider.id, enabled)
}
activeProviderId={activeProviderId}
/> />
))} ))}
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
); );
return (
<div className="mt-4 space-y-4">
<AnimatePresence>
{isSearchOpen && (
<motion.div
key="provider-search"
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0"
>
<div className="p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md">
<div className="relative flex items-center gap-2">
<Search className="absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t("provider.searchPlaceholder", {
defaultValue: "Search name, notes, or URL...",
})}
aria-label={t("provider.searchAriaLabel", {
defaultValue: "Search providers",
})}
className="pr-16 pl-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="sm"
className="absolute text-xs -translate-y-1/2 right-11 top-1/2"
onClick={() => setSearchTerm("")}
>
{t("common.clear", { defaultValue: "Clear" })}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto"
onClick={() => setIsSearchOpen(false)}
aria-label={t("provider.searchCloseAriaLabel", {
defaultValue: "Close provider search",
})}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>
{t("provider.searchScopeHint", {
defaultValue: "Matches provider name, notes, and URL.",
})}
</span>
<span>
{t("provider.searchCloseHint", {
defaultValue: "Press Esc to close",
})}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{filteredProviders.length === 0 ? (
<div className="px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground">
{t("provider.noSearchResults", {
defaultValue: "No providers match your search.",
})}
</div>
) : (
renderProviderList()
)}
</div>
);
} }
interface SortableProviderCardProps { interface SortableProviderCardProps {
@@ -125,6 +315,12 @@ interface SortableProviderCardProps {
isTesting: boolean; isTesting: boolean;
isProxyRunning: boolean; isProxyRunning: boolean;
isProxyTakeover: boolean; isProxyTakeover: boolean;
// 故障转移相关
isAutoFailoverEnabled: boolean;
failoverPriority?: number;
isInFailoverQueue: boolean;
onToggleFailover: (enabled: boolean) => void;
activeProviderId?: string;
} }
function SortableProviderCard({ function SortableProviderCard({
@@ -141,6 +337,11 @@ function SortableProviderCard({
isTesting, isTesting,
isProxyRunning, isProxyRunning,
isProxyTakeover, isProxyTakeover,
isAutoFailoverEnabled,
failoverPriority,
isInFailoverQueue,
onToggleFailover,
activeProviderId,
}: SortableProviderCardProps) { }: SortableProviderCardProps) {
const { const {
setNodeRef, setNodeRef,
@@ -179,6 +380,12 @@ function SortableProviderCard({
listeners, listeners,
isDragging, isDragging,
}} }}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
failoverPriority={failoverPriority}
isInFailoverQueue={isInFailoverQueue}
onToggleFailover={onToggleFailover}
activeProviderId={activeProviderId}
/> />
</div> </div>
); );
@@ -30,7 +30,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${ const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
disabled disabled
? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed" ? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
: "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20" : "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
}`; }`;
@@ -52,7 +52,15 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
<button <button
type="button" type="button"
className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center" className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center"
title={currentIcon ? "点击更换图标" : "点击选择图标"} title={
currentIcon
? t("providerIcon.clickToChange", {
defaultValue: "点击更换图标",
})
: t("providerIcon.clickToSelect", {
defaultValue: "点击选择图标",
})
}
> >
<ProviderIcon <ProviderIcon
icon={currentIcon} icon={currentIcon}
@@ -145,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
<FormItem> <FormItem>
<FormLabel>{t("provider.websiteUrl")}</FormLabel> <FormLabel>{t("provider.websiteUrl")}</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="https://" /> <Input
{...field}
placeholder={t("providerForm.websiteUrlPlaceholder")}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form"; import { FormLabel } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import EndpointSpeedTest from "./EndpointSpeedTest"; import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared"; import { ApiKeySection, EndpointField } from "./shared";
@@ -39,12 +40,14 @@ interface ClaudeFormFieldsProps {
// Model Selector // Model Selector
shouldShowModelSelector: boolean; shouldShowModelSelector: boolean;
claudeModel: string; claudeModel: string;
reasoningModel: string;
defaultHaikuModel: string; defaultHaikuModel: string;
defaultSonnetModel: string; defaultSonnetModel: string;
defaultOpusModel: string; defaultOpusModel: string;
onModelChange: ( onModelChange: (
field: field:
| "ANTHROPIC_MODEL" | "ANTHROPIC_MODEL"
| "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL", | "ANTHROPIC_DEFAULT_OPUS_MODEL",
@@ -53,6 +56,11 @@ interface ClaudeFormFieldsProps {
// Speed Test Endpoints // Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[]; speedTestEndpoints: EndpointCandidate[];
// OpenRouter Compat
showOpenRouterCompatToggle: boolean;
openRouterCompatEnabled: boolean;
onOpenRouterCompatChange: (enabled: boolean) => void;
} }
export function ClaudeFormFields({ export function ClaudeFormFields({
@@ -77,11 +85,15 @@ export function ClaudeFormFields({
onCustomEndpointsChange, onCustomEndpointsChange,
shouldShowModelSelector, shouldShowModelSelector,
claudeModel, claudeModel,
reasoningModel,
defaultHaikuModel, defaultHaikuModel,
defaultSonnetModel, defaultSonnetModel,
defaultOpusModel, defaultOpusModel,
onModelChange, onModelChange,
speedTestEndpoints, speedTestEndpoints,
showOpenRouterCompatToggle,
openRouterCompatEnabled,
onOpenRouterCompatChange,
}: ClaudeFormFieldsProps) { }: ClaudeFormFieldsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -162,6 +174,28 @@ export function ClaudeFormFields({
/> />
)} )}
{showOpenRouterCompatToggle && (
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("providerForm.openrouterCompatMode", {
defaultValue: "OpenRouter 兼容模式",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.openrouterCompatModeHint", {
defaultValue:
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
})}
</p>
</div>
<Switch
checked={openRouterCompatEnabled}
onCheckedChange={onOpenRouterCompatChange}
/>
</div>
)}
{/* 模型选择器 */} {/* 模型选择器 */}
{shouldShowModelSelector && ( {shouldShowModelSelector && (
<div className="space-y-3"> <div className="space-y-3">
@@ -185,6 +219,27 @@ export function ClaudeFormFields({
/> />
</div> </div>
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel", {
defaultValue: "推理模型 (Thinking)",
})}
</FormLabel>
<Input
id="reasoningModel"
type="text"
value={reasoningModel}
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
{/* 默认 Haiku */} {/* 默认 Haiku */}
<div className="space-y-2"> <div className="space-y-2">
<FormLabel htmlFor="claudeDefaultHaikuModel"> <FormLabel htmlFor="claudeDefaultHaikuModel">
@@ -162,6 +162,8 @@ export function ProviderForm({
mode: "onSubmit", mode: "onSubmit",
}); });
const settingsConfigValue = form.watch("settingsConfig");
// 使用 API Key hook // 使用 API Key hook
const { const {
apiKey, apiKey,
@@ -187,9 +189,10 @@ export function ProviderForm({
}, },
}); });
// 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型) // 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型)
const { const {
claudeModel, claudeModel,
reasoningModel,
defaultHaikuModel, defaultHaikuModel,
defaultSonnetModel, defaultSonnetModel,
defaultOpusModel, defaultOpusModel,
@@ -199,6 +202,53 @@ export function ProviderForm({
onConfigChange: (config) => form.setValue("settingsConfig", config), onConfigChange: (config) => form.setValue("settingsConfig", config),
}); });
const isOpenRouterProvider = useMemo(() => {
if (appId !== "claude") return false;
const normalized = baseUrl.trim().toLowerCase();
if (normalized.includes("openrouter.ai")) {
return true;
}
try {
const config = JSON.parse(settingsConfigValue || "{}");
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
return typeof envUrl === "string" && envUrl.includes("openrouter.ai");
} catch {
return false;
}
}, [appId, baseUrl, settingsConfigValue]);
const openRouterCompatEnabled = useMemo(() => {
if (!isOpenRouterProvider) return false;
try {
const config = JSON.parse(settingsConfigValue || "{}");
const raw = config?.openrouter_compat_mode;
if (typeof raw === "boolean") return raw;
if (typeof raw === "number") return raw !== 0;
if (typeof raw === "string") {
const normalized = raw.trim().toLowerCase();
return normalized === "true" || normalized === "1";
}
} catch {
// ignore
}
return true;
}, [isOpenRouterProvider, settingsConfigValue]);
const handleOpenRouterCompatChange = useCallback(
(enabled: boolean) => {
try {
const currentConfig = JSON.parse(
form.getValues("settingsConfig") || "{}",
);
currentConfig.openrouter_compat_mode = enabled;
form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2));
} catch {
// ignore
}
},
[form],
);
// 使用 Codex 配置 hook (仅 Codex 模式) // 使用 Codex 配置 hook (仅 Codex 模式)
const { const {
codexAuth, codexAuth,
@@ -789,11 +839,15 @@ export function ProviderForm({
} }
shouldShowModelSelector={category !== "official"} shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel} claudeModel={claudeModel}
reasoningModel={reasoningModel}
defaultHaikuModel={defaultHaikuModel} defaultHaikuModel={defaultHaikuModel}
defaultSonnetModel={defaultSonnetModel} defaultSonnetModel={defaultSonnetModel}
defaultOpusModel={defaultOpusModel} defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange} onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints} speedTestEndpoints={speedTestEndpoints}
showOpenRouterCompatToggle={isOpenRouterProvider}
openRouterCompatEnabled={openRouterCompatEnabled}
onOpenRouterCompatChange={handleOpenRouterCompatChange}
/> />
)} )}
@@ -149,10 +149,7 @@ export function ProviderPresetSelector({
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`} className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
style={getPresetButtonStyle(isSelected, entry.preset)} style={getPresetButtonStyle(isSelected, entry.preset)}
title={ title={
presetCategoryLabels[category] ?? presetCategoryLabels[category] ?? t("providerPreset.other")
t("providerPreset.categoryOther", {
defaultValue: "其他",
})
} }
> >
{renderPresetIcon(entry.preset)} {renderPresetIcon(entry.preset)}
@@ -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);
@@ -7,13 +7,14 @@ interface UseModelStateProps {
/** /**
* *
* ANTHROPIC_MODEL ANTHROPIC_SMALL_FAST_MODEL * ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL
*/ */
export function useModelState({ export function useModelState({
settingsConfig, settingsConfig,
onConfigChange, onConfigChange,
}: UseModelStateProps) { }: UseModelStateProps) {
const [claudeModel, setClaudeModel] = useState(""); const [claudeModel, setClaudeModel] = useState("");
const [reasoningModel, setReasoningModel] = useState("");
const [defaultHaikuModel, setDefaultHaikuModel] = useState(""); const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
const [defaultSonnetModel, setDefaultSonnetModel] = useState(""); const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
const [defaultOpusModel, setDefaultOpusModel] = useState(""); const [defaultOpusModel, setDefaultOpusModel] = useState("");
@@ -29,6 +30,10 @@ export function useModelState({
const env = cfg?.env || {}; const env = cfg?.env || {};
const model = const model =
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
const reasoning =
typeof env.ANTHROPIC_REASONING_MODEL === "string"
? env.ANTHROPIC_REASONING_MODEL
: "";
const small = const small =
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
? env.ANTHROPIC_SMALL_FAST_MODEL ? env.ANTHROPIC_SMALL_FAST_MODEL
@@ -47,6 +52,7 @@ export function useModelState({
: model || small; : model || small;
setClaudeModel(model || ""); setClaudeModel(model || "");
setReasoningModel(reasoning || "");
setDefaultHaikuModel(haiku || ""); setDefaultHaikuModel(haiku || "");
setDefaultSonnetModel(sonnet || ""); setDefaultSonnetModel(sonnet || "");
setDefaultOpusModel(opus || ""); setDefaultOpusModel(opus || "");
@@ -59,12 +65,14 @@ export function useModelState({
( (
field: field:
| "ANTHROPIC_MODEL" | "ANTHROPIC_MODEL"
| "ANTHROPIC_REASONING_MODEL"
| "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL", | "ANTHROPIC_DEFAULT_OPUS_MODEL",
value: string, value: string,
) => { ) => {
if (field === "ANTHROPIC_MODEL") setClaudeModel(value); if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
setDefaultHaikuModel(value); setDefaultHaikuModel(value);
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL") if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
@@ -98,6 +106,8 @@ export function useModelState({
return { return {
claudeModel, claudeModel,
setClaudeModel, setClaudeModel,
reasoningModel,
setReasoningModel,
defaultHaikuModel, defaultHaikuModel,
setDefaultHaikuModel, setDefaultHaikuModel,
defaultSonnetModel, defaultSonnetModel,
+207 -131
View File
@@ -6,51 +6,67 @@ import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2, Info } from "lucide-react"; import { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy";
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
export interface AutoFailoverConfigPanelProps { export interface AutoFailoverConfigPanelProps {
enabled: boolean; appType: string;
onEnabledChange: (enabled: boolean) => void; disabled?: boolean;
} }
export function AutoFailoverConfigPanel({ export function AutoFailoverConfigPanel({
enabled, appType,
onEnabledChange: _onEnabledChange, disabled = false,
}: AutoFailoverConfigPanelProps) { }: AutoFailoverConfigPanelProps) {
// Note: onEnabledChange is currently unused but kept in the interface
// for potential future use by parent components
void _onEnabledChange;
const { t } = useTranslation(); const { t } = useTranslation();
const { data: config, isLoading, error } = useCircuitBreakerConfig(); const { data: config, isLoading, error } = useAppProxyConfig(appType);
const updateConfig = useUpdateCircuitBreakerConfig(); const updateConfig = useUpdateAppProxyConfig();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
failureThreshold: 5, autoFailoverEnabled: false,
successThreshold: 2, maxRetries: 3,
timeoutSeconds: 60, streamingFirstByteTimeout: 30,
errorRateThreshold: 0.5, streamingIdleTimeout: 60,
minRequests: 10, nonStreamingTimeout: 300,
circuitFailureThreshold: 5,
circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60,
circuitErrorRateThreshold: 0.5,
circuitMinRequests: 10,
}); });
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setFormData({ setFormData({
...config, autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
}); });
} }
}, [config]); }, [config]);
const handleSave = async () => { const handleSave = async () => {
if (!config) return;
try { try {
await updateConfig.mutateAsync({ await updateConfig.mutateAsync({
failureThreshold: formData.failureThreshold, appType,
successThreshold: formData.successThreshold, enabled: config.enabled,
timeoutSeconds: formData.timeoutSeconds, autoFailoverEnabled: formData.autoFailoverEnabled,
errorRateThreshold: formData.errorRateThreshold, maxRetries: formData.maxRetries,
minRequests: formData.minRequests, streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
streamingIdleTimeout: formData.streamingIdleTimeout,
nonStreamingTimeout: formData.nonStreamingTimeout,
circuitFailureThreshold: formData.circuitFailureThreshold,
circuitSuccessThreshold: formData.circuitSuccessThreshold,
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
circuitMinRequests: formData.circuitMinRequests,
}); });
toast.success( toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"), t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({
const handleReset = () => { const handleReset = () => {
if (config) { if (config) {
setFormData({ setFormData({
...config, autoFailoverEnabled: config.autoFailoverEnabled,
maxRetries: config.maxRetries,
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
streamingIdleTimeout: config.streamingIdleTimeout,
nonStreamingTimeout: config.nonStreamingTimeout,
circuitFailureThreshold: config.circuitFailureThreshold,
circuitSuccessThreshold: config.circuitSuccessThreshold,
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
circuitMinRequests: config.circuitMinRequests,
}); });
} }
}; };
@@ -79,16 +104,10 @@ export function AutoFailoverConfigPanel({
); );
} }
const isDisabled = disabled || updateConfig.isPending;
return ( return (
<div className="border-0 rounded-none shadow-none bg-transparent"> <div className="border-0 rounded-none shadow-none bg-transparent">
{/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
Since we need it in the accordion header, and this component is inside the content, we can use a portal or
absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
Better yet, let's just render the content directly without the wrapping Card header/collapse logic
since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
*/}
<div className="space-y-4"> <div className="space-y-4">
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
@@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="failureThreshold"> <Label htmlFor={`maxRetries-${appType}`}>
{t("proxy.autoFailover.failureThreshold", "失败阈值")} {t("proxy.autoFailover.maxRetries", "最大重试次数")}
</Label> </Label>
<Input <Input
id="failureThreshold" id={`maxRetries-${appType}`}
type="number" type="number"
min="1" min="0"
max="20" max="10"
value={formData.failureThreshold} value={formData.maxRetries}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
failureThreshold: parseInt(e.target.value) || 5, maxRetries: parseInt(e.target.value) || 3,
}) })
} }
disabled={!enabled} disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.maxRetriesHint",
"请求失败时的重试次数(0-10",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`failureThreshold-${appType}`}>
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
</Label>
<Input
id={`failureThreshold-${appType}`}
type="number"
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) =>
setFormData({
...formData,
circuitFailureThreshold: parseInt(e.target.value) || 5,
})
}
disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({
)} )}
</p> </p>
</div> </div>
</div>
</div>
{/* 超时配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.timeoutSettings", "超时配置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="timeoutSeconds"> <Label htmlFor={`streamingFirstByte-${appType}`}>
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")} {t(
"proxy.autoFailover.streamingFirstByte",
"流式首字节超时(秒)",
)}
</Label> </Label>
<Input <Input
id="timeoutSeconds" id={`streamingFirstByte-${appType}`}
type="number" type="number"
min="10" min="0"
max="300" max="180"
value={formData.timeoutSeconds} value={formData.streamingFirstByteTimeout}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
timeoutSeconds: parseInt(e.target.value) || 60, streamingFirstByteTimeout: parseInt(e.target.value) || 30,
}) })
} }
disabled={!enabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
"proxy.autoFailover.timeoutHint", "proxy.autoFailover.streamingFirstByteHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)", "等待首个数据块的最大时间",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`streamingIdle-${appType}`}>
{t("proxy.autoFailover.streamingIdle", "流式静默超时(秒)")}
</Label>
<Input
id={`streamingIdle-${appType}`}
type="number"
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.streamingIdleHint",
"数据块之间的最大间隔",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`nonStreaming-${appType}`}>
{t("proxy.autoFailover.nonStreaming", "非流式超时(秒)")}
</Label>
<Input
id={`nonStreaming-${appType}`}
type="number"
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: parseInt(e.target.value) || 300,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.nonStreamingHint",
"非流式请求的总超时时间",
)} )}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* 熔断器高级配置 */} {/* 熔断器配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4"> <div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold"> <h4 className="text-sm font-semibold">
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")} {t("proxy.autoFailover.circuitBreakerSettings", "熔断器置")}
</h4> </h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="successThreshold"> <Label htmlFor={`successThreshold-${appType}`}>
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")} {t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
</Label> </Label>
<Input <Input
id="successThreshold" id={`successThreshold-${appType}`}
type="number" type="number"
min="1" min="1"
max="10" max="10"
value={formData.successThreshold} value={formData.circuitSuccessThreshold}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
successThreshold: parseInt(e.target.value) || 2, circuitSuccessThreshold: parseInt(e.target.value) || 2,
}) })
} }
disabled={!enabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -201,23 +310,50 @@ export function AutoFailoverConfigPanel({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="errorRateThreshold"> <Label htmlFor={`timeoutSeconds-${appType}`}>
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id={`timeoutSeconds-${appType}`}
type="number"
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`errorRateThreshold-${appType}`}>
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")} {t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
</Label> </Label>
<Input <Input
id="errorRateThreshold" id={`errorRateThreshold-${appType}`}
type="number" type="number"
min="0" min="0"
max="100" max="100"
step="5" step="5"
value={Math.round(formData.errorRateThreshold * 100)} value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100, circuitErrorRateThreshold:
(parseInt(e.target.value) || 50) / 100,
}) })
} }
disabled={!enabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="minRequests"> <Label htmlFor={`minRequests-${appType}`}>
{t("proxy.autoFailover.minRequests", "最小请求数")} {t("proxy.autoFailover.minRequests", "最小请求数")}
</Label> </Label>
<Input <Input
id="minRequests" id={`minRequests-${appType}`}
type="number" type="number"
min="5" min="5"
max="100" max="100"
value={formData.minRequests} value={formData.circuitMinRequests}
onChange={(e) => onChange={(e) =>
setFormData({ setFormData({
...formData, ...formData,
minRequests: parseInt(e.target.value) || 10, circuitMinRequests: parseInt(e.target.value) || 10,
}) })
} }
disabled={!enabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
@@ -257,17 +393,10 @@ export function AutoFailoverConfigPanel({
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<Button <Button variant="outline" onClick={handleReset} disabled={isDisabled}>
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !enabled}
>
{t("common.reset", "重置")} {t("common.reset", "重置")}
</Button> </Button>
<Button <Button onClick={handleSave} disabled={isDisabled}>
onClick={handleSave}
disabled={updateConfig.isPending || !enabled}
>
{updateConfig.isPending ? ( {updateConfig.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -281,59 +410,6 @@ export function AutoFailoverConfigPanel({
)} )}
</Button> </Button>
</div> </div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("proxy.autoFailover.explanationTitle", "工作原理")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
</strong>
{t(
"proxy.autoFailover.failureThresholdExplain",
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
</strong>
{t(
"proxy.autoFailover.timeoutExplain",
"熔断器打开后,等待此时间后尝试半开状态",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
</strong>
{t(
"proxy.autoFailover.successThresholdExplain",
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
</strong>
{t(
"proxy.autoFailover.errorRateExplain",
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
)}
</li>
</ul>
</div>
</div> </div>
</div> </div>
); );
+52 -166
View File
@@ -2,37 +2,14 @@
* *
* *
* *
* -
* - / * - /
* - / * - sort_index
*/ */
import { useState, useCallback, useMemo } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
GripVertical,
Plus,
Trash2,
Loader2,
Info,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -51,8 +28,8 @@ import {
useAvailableProvidersForFailover, useAvailableProvidersForFailover,
useAddToFailoverQueue, useAddToFailoverQueue,
useRemoveFromFailoverQueue, useRemoveFromFailoverQueue,
useReorderFailoverQueue, useAutoFailoverEnabled,
useSetFailoverItemEnabled, useSetAutoFailoverEnabled,
} from "@/lib/query/failover"; } from "@/lib/query/failover";
interface FailoverQueueManagerProps { interface FailoverQueueManagerProps {
@@ -67,6 +44,10 @@ export function FailoverQueueManager({
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedProviderId, setSelectedProviderId] = useState<string>(""); const [selectedProviderId, setSelectedProviderId] = useState<string>("");
// 故障转移开关状态(每个应用独立)
const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);
const setFailoverEnabled = useSetAutoFailoverEnabled();
// 查询数据 // 查询数据
const { const {
data: queue, data: queue,
@@ -79,59 +60,11 @@ export function FailoverQueueManager({
// Mutations // Mutations
const addToQueue = useAddToFailoverQueue(); const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue(); const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
const setItemEnabled = useSetFailoverItemEnabled();
// 拖拽配置 // 切换故障转移开关
const sensors = useSensors( const handleToggleFailover = (enabled: boolean) => {
useSensor(PointerSensor, { setFailoverEnabled.mutate({ appType, enabled });
activationConstraint: { distance: 8 }, };
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 排序后的队列
const sortedQueue = useMemo(() => {
if (!queue) return [];
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
}, [queue]);
// 处理拖拽结束
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !sortedQueue) return;
const oldIndex = sortedQueue.findIndex(
(item) => item.providerId === active.id,
);
const newIndex = sortedQueue.findIndex(
(item) => item.providerId === over.id,
);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
const providerIds = reordered.map((item) => item.providerId);
try {
await reorderQueue.mutateAsync({ appType, providerIds });
toast.success(
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
": " +
String(error),
);
}
},
[sortedQueue, appType, reorderQueue, t],
);
// 添加供应商到队列 // 添加供应商到队列
const handleAddProvider = async () => { const handleAddProvider = async () => {
@@ -171,19 +104,6 @@ export function FailoverQueueManager({
} }
}; };
// 切换启用状态
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
try {
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
} catch (error) {
toast.error(
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
": " +
String(error),
);
}
};
if (isQueueLoading) { if (isQueueLoading) {
return ( return (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
@@ -203,13 +123,41 @@ export function FailoverQueueManager({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 自动故障转移开关 */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{t("proxy.failover.autoSwitch", {
defaultValue: "自动故障转移",
})}
</span>
{isFailoverEnabled && (
<span className="px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
{t("common.enabled", { defaultValue: "已开启" })}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("proxy.failover.autoSwitchDescription", {
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
})}
</p>
</div>
<Switch
checked={isFailoverEnabled}
onCheckedChange={handleToggleFailover}
disabled={disabled || setFailoverEnabled.isPending}
/>
</div>
{/* 说明信息 */} {/* 说明信息 */}
<Alert className="border-blue-500/40 bg-blue-500/10"> <Alert className="border-blue-500/40 bg-blue-500/10">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription className="text-sm"> <AlertDescription className="text-sm">
{t( {t(
"proxy.failoverQueue.info", "proxy.failoverQueue.info",
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。", "队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。",
)} )}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -260,7 +208,7 @@ export function FailoverQueueManager({
</div> </div>
{/* 队列列表 */} {/* 队列列表 */}
{sortedQueue.length === 0 ? ( {!queue || queue.length === 0 ? (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center"> <div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t(
@@ -270,39 +218,26 @@ export function FailoverQueueManager({
</p> </p>
</div> </div>
) : ( ) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedQueue.map((item) => item.providerId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2"> <div className="space-y-2">
{sortedQueue.map((item, index) => ( {queue.map((item, index) => (
<SortableQueueItem <QueueItem
key={item.providerId} key={item.providerId}
item={item} item={item}
index={index} index={index}
disabled={disabled} disabled={disabled}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider} onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending} isRemoving={removeFromQueue.isPending}
isToggling={setItemEnabled.isPending}
/> />
))} ))}
</div> </div>
</SortableContext>
</DndContext>
)} )}
{/* 队列说明 */} {/* 队列说明 */}
{sortedQueue.length > 0 && ( {queue && queue.length > 0 && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
"proxy.failoverQueue.dragHint", "proxy.failoverQueue.orderHint",
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。", "队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
)} )}
</p> </p>
)} )}
@@ -310,65 +245,29 @@ export function FailoverQueueManager({
); );
} }
interface SortableQueueItemProps { interface QueueItemProps {
item: FailoverQueueItem; item: FailoverQueueItem;
index: number; index: number;
disabled: boolean; disabled: boolean;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string) => void; onRemove: (providerId: string) => void;
isRemoving: boolean; isRemoving: boolean;
isToggling: boolean;
} }
function SortableQueueItem({ function QueueItem({
item, item,
index, index,
disabled, disabled,
onToggleEnabled,
onRemove, onRemove,
isRemoving, isRemoving,
isToggling, }: QueueItemProps) {
}: SortableQueueItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: item.providerId, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return ( return (
<div <div
ref={setNodeRef}
style={style}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors", "flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
isDragging && "opacity-50 shadow-lg",
!item.enabled && "opacity-60",
)} )}
> >
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
{...attributes}
{...listeners}
disabled={disabled}
aria-label={t("provider.dragHandle", "拖拽排序")}
>
<GripVertical className="h-5 w-5" />
</button>
{/* 序号 */} {/* 序号 */}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium"> <div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{index + 1} {index + 1}
@@ -376,24 +275,11 @@ function SortableQueueItem({
{/* 供应商名称 */} {/* 供应商名称 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span <span className="text-sm font-medium truncate block">
className={cn(
"text-sm font-medium truncate block",
!item.enabled && "text-muted-foreground line-through",
)}
>
{item.providerName} {item.providerName}
</span> </span>
</div> </div>
{/* 启用开关 */}
<Switch
checked={item.enabled}
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
disabled={disabled || isToggling}
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
/>
{/* 删除按钮 */} {/* 删除按钮 */}
<Button <Button
variant="ghost" variant="ghost"
+292 -54
View File
@@ -1,24 +1,54 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { import {
Activity, Activity,
Clock, Clock,
TrendingUp, TrendingUp,
Server, Server,
ListOrdered, ListOrdered,
Settings, Save,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { useFailoverQueue } from "@/lib/query/failover"; import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover"; import { useProviderHealth } from "@/lib/query/failover";
import {
useProxyTakeoverStatus,
useSetProxyTakeoverForApp,
useGlobalProxyConfig,
useUpdateGlobalProxyConfig,
} from "@/lib/query/proxy";
import type { ProxyStatus } from "@/types/proxy"; import type { ProxyStatus } from "@/types/proxy";
import { useTranslation } from "react-i18next";
export function ProxyPanel() { export function ProxyPanel() {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus(); const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
// 获取应用接管状态
const { data: takeoverStatus } = useProxyTakeoverStatus();
const setTakeoverForApp = useSetProxyTakeoverForApp();
// 获取全局代理配置
const { data: globalConfig } = useGlobalProxyConfig();
const updateGlobalConfig = useUpdateGlobalProxyConfig();
// 监听地址/端口的本地状态
const [listenAddress, setListenAddress] = useState("127.0.0.1");
const [listenPort, setListenPort] = useState(5000);
// 同步全局配置到本地状态
useEffect(() => {
if (globalConfig) {
setListenAddress(globalConfig.listenAddress);
setListenPort(globalConfig.listenPort);
}
}, [globalConfig]);
// 获取所有三个应用类型的故障转移队列(不包含当前供应商) // 获取所有三个应用类型的故障转移队列(不包含当前供应商)
// 当前供应商始终优先,队列仅用于失败后的备用顺序 // 当前供应商始终优先,队列仅用于失败后的备用顺序
@@ -26,6 +56,69 @@ export function ProxyPanel() {
const { data: codexQueue = [] } = useFailoverQueue("codex"); const { data: codexQueue = [] } = useFailoverQueue("codex");
const { data: geminiQueue = [] } = useFailoverQueue("gemini"); const { data: geminiQueue = [] } = useFailoverQueue("gemini");
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
try {
await setTakeoverForApp.mutateAsync({ appType, enabled });
toast.success(
enabled
? t("proxy.takeover.enabled", {
app: appType,
defaultValue: `${appType} 接管已启用`,
})
: t("proxy.takeover.disabled", {
app: appType,
defaultValue: `${appType} 接管已关闭`,
}),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.takeover.failed", {
defaultValue: "切换接管状态失败",
}),
);
}
};
const handleLoggingChange = async (enabled: boolean) => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
enableLogging: enabled,
});
toast.success(
enabled
? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" })
: t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }),
);
}
};
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
listenAddress,
listenPort,
});
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }),
);
}
};
const formatUptime = (seconds: number): string => { const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
@@ -47,18 +140,11 @@ 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 mb-2">
<p className="text-xs text-muted-foreground"></p> {t("proxy.panel.serviceAddress", {
<Button defaultValue: "服务地址",
size="sm" })}
variant="ghost" </p>
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"> <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}
@@ -70,16 +156,28 @@ export function ProxyPanel() {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`http://${status.address}:${status.port}`, `http://${status.address}:${status.port}`,
); );
toast.success("地址已复制", { closeButton: true }); toast.success(
t("proxy.panel.addressCopied", {
defaultValue: "地址已复制",
}),
{ closeButton: true },
);
}} }}
> >
{t("common.copy")}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground mt-2">
{t("proxy.settings.restartRequired", {
defaultValue: "修改监听地址/端口需要先停止代理服务",
})}
</p>
</div> </div>
<div className="pt-3 border-t border-border space-y-2"> <div className="pt-3 border-t border-border space-y-2">
<p className="text-xs text-muted-foreground">使</p> <p className="text-xs text-muted-foreground">
{t("provider.inUse")}
</p>
{status.active_targets && status.active_targets.length > 0 ? ( {status.active_targets && status.active_targets.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{status.active_targets.map((target) => ( {status.active_targets.map((target) => (
@@ -101,18 +199,79 @@ export function ProxyPanel() {
</div> </div>
) : status.current_provider ? ( ) : status.current_provider ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Provider{" "} {t("proxy.panel.currentProvider", {
defaultValue: "当前 Provider",
})}{" "}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{status.current_provider} {status.current_provider}
</span> </span>
</p> </p>
) : ( ) : (
<p className="text-sm text-yellow-600 dark:text-yellow-400"> <p className="text-sm text-yellow-600 dark:text-yellow-400">
Provider {t("proxy.panel.waitingFirstRequest", {
defaultValue: "当前 Provider:等待首次请求…",
})}
</p> </p>
)} )}
</div> </div>
{/* 应用接管开关 */}
<div className="pt-3 border-t border-border space-y-3">
<p className="text-xs text-muted-foreground">
{t("proxyConfig.appTakeover", {
defaultValue: "应用接管",
})}
</p>
<div className="grid gap-2 sm:grid-cols-3">
{(["claude", "codex", "gemini"] as const).map((appType) => {
const isEnabled =
takeoverStatus?.[
appType as keyof typeof takeoverStatus
] ?? false;
return (
<div
key={appType}
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
>
<span className="text-sm font-medium capitalize">
{appType}
</span>
<Switch
checked={isEnabled}
onCheckedChange={(checked) =>
handleTakeoverChange(appType, checked)
}
disabled={setTakeoverForApp.isPending}
/>
</div>
);
})}
</div>
</div>
{/* 日志记录开关 */}
<div className="pt-3 border-t border-border">
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</Label>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</p>
</div>
<Switch
checked={globalConfig?.enableLogging ?? true}
onCheckedChange={handleLoggingChange}
disabled={updateGlobalConfig.isPending}
/>
</div>
</div>
{/* 供应商队列 - 按应用类型分组展示 */} {/* 供应商队列 - 按应用类型分组展示 */}
{(claudeQueue.length > 0 || {(claudeQueue.length > 0 ||
codexQueue.length > 0 || codexQueue.length > 0 ||
@@ -121,7 +280,7 @@ export function ProxyPanel() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" /> <ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.title")}
</p> </p>
</div> </div>
@@ -130,10 +289,7 @@ export function ProxyPanel() {
<ProviderQueueGroup <ProviderQueueGroup
appType="claude" appType="claude"
appLabel="Claude" appLabel="Claude"
targets={claudeQueue targets={claudeQueue.map((item) => ({
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId, id: item.providerId,
name: item.providerName, name: item.providerName,
}))} }))}
@@ -146,10 +302,7 @@ export function ProxyPanel() {
<ProviderQueueGroup <ProviderQueueGroup
appType="codex" appType="codex"
appLabel="Codex" appLabel="Codex"
targets={codexQueue targets={codexQueue.map((item) => ({
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId, id: item.providerId,
name: item.providerName, name: item.providerName,
}))} }))}
@@ -162,10 +315,7 @@ export function ProxyPanel() {
<ProviderQueueGroup <ProviderQueueGroup
appType="gemini" appType="gemini"
appLabel="Gemini" appLabel="Gemini"
targets={geminiQueue targets={geminiQueue.map((item) => ({
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId, id: item.providerId,
name: item.providerName, name: item.providerName,
}))} }))}
@@ -179,52 +329,139 @@ export function ProxyPanel() {
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<StatCard <StatCard
icon={<Activity className="h-4 w-4" />} icon={<Activity className="h-4 w-4" />}
label="活跃连接" label={t("proxy.panel.stats.activeConnections", {
defaultValue: "活跃连接",
})}
value={status.active_connections} value={status.active_connections}
/> />
<StatCard <StatCard
icon={<TrendingUp className="h-4 w-4" />} icon={<TrendingUp className="h-4 w-4" />}
label="总请求数" label={t("proxy.panel.stats.totalRequests", {
defaultValue: "总请求数",
})}
value={status.total_requests} value={status.total_requests}
/> />
<StatCard <StatCard
icon={<Clock className="h-4 w-4" />} icon={<Clock className="h-4 w-4" />}
label="成功率" label={t("proxy.panel.stats.successRate", {
defaultValue: "成功率",
})}
value={`${status.success_rate.toFixed(1)}%`} value={`${status.success_rate.toFixed(1)}%`}
variant={status.success_rate > 90 ? "success" : "warning"} variant={status.success_rate > 90 ? "success" : "warning"}
/> />
<StatCard <StatCard
icon={<Clock className="h-4 w-4" />} icon={<Clock className="h-4 w-4" />}
label="运行时间" label={t("proxy.panel.stats.uptime", {
defaultValue: "运行时间",
})}
value={formatUptime(status.uptime_seconds)} value={formatUptime(status.uptime_seconds)}
/> />
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-center py-10 text-muted-foreground"> <div className="space-y-6">
{/* 空白区域避免冲突 */}
<div className="h-4"></div>
{/* 基础设置 - 监听地址/端口 */}
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="listen-address">
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</Label>
<Input
id="listen-address"
value={listenAddress}
onChange={(e) => setListenAddress(e.target.value)}
placeholder="127.0.0.1"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="listen-port">
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</Label>
<Input
id="listen-port"
type="number"
value={listenPort}
onChange={(e) =>
setListenPort(parseInt(e.target.value) || 5000)
}
placeholder="5000"
/>
<p className="text-xs text-muted-foreground">
{t("proxy.settings.fields.listenPort.description", {
defaultValue: "代理服务器监听的端口号(1024 ~ 65535",
})}
</p>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSaveBasicConfig}
disabled={updateGlobalConfig.isPending}
>
{updateGlobalConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", { defaultValue: "保存中..." })}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", { defaultValue: "保存" })}
</>
)}
</Button>
</div>
</div>
{/* 代理服务已停止提示 */}
<div className="text-center py-6 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4"> <div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" /> <Server className="h-8 w-8" />
</div> </div>
<p className="text-base font-medium text-foreground mb-1"> <p className="text-base font-medium text-foreground mb-1">
{t("proxy.panel.stoppedTitle", {
defaultValue: "代理服务已停止",
})}
</p> </p>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground">
使 {t("proxy.panel.stoppedDescription", {
defaultValue: "使用右上角开关即可启动服务",
})}
</p> </p>
<Button </div>
size="sm"
variant="outline"
onClick={() => setShowSettings(true)}
className="gap-1.5"
>
<Settings className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</section> </section>
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
</> </>
); );
} }
@@ -319,6 +556,7 @@ function ProviderQueueItem({
appType, appType,
isCurrent, isCurrent,
}: ProviderQueueItemProps) { }: ProviderQueueItemProps) {
const { t } = useTranslation();
const { data: health } = useProviderHealth(provider.id, appType); const { data: health } = useProviderHealth(provider.id, appType);
return ( return (
@@ -344,7 +582,7 @@ function ProviderQueueItem({
</span> </span>
{isCurrent && ( {isCurrent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary"> <span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
使 {t("provider.inUse")}
</span> </span>
)} )}
</div> </div>
@@ -1,397 +0,0 @@
/**
*
*/
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useProxyConfig } from "@/hooks/useProxyConfig";
import { useEffect, useMemo } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理)
type ProxyConfigForm = Omit<ProxyConfig, "enabled">;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
.number()
.min(
0,
t("proxy.settings.validation.timeoutNonNegative", {
defaultValue: "超时时间不能为负数",
}),
)
.max(
600,
t("proxy.settings.validation.timeoutMax", {
defaultValue: "超时时间最多600秒",
}),
)
.refine((value) => value === 0 || value >= 10, {
message: t("proxy.settings.validation.timeoutRange", {
defaultValue: "请输入 0 或 10-600 之间的数值",
}),
});
return z.object({
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
t("proxy.settings.validation.addressInvalid", {
defaultValue: "请输入有效的IP地址",
}),
),
listen_port: z
.number()
.min(
1024,
t("proxy.settings.validation.portMin", {
defaultValue: "端口必须大于1024",
}),
)
.max(
65535,
t("proxy.settings.validation.portMax", {
defaultValue: "端口必须小于65535",
}),
),
max_retries: z
.number()
.min(
0,
t("proxy.settings.validation.retryMin", {
defaultValue: "重试次数不能为负",
}),
)
.max(
10,
t("proxy.settings.validation.retryMax", {
defaultValue: "重试次数不能超过10",
}),
),
request_timeout: requestTimeoutSchema,
enable_logging: z.boolean(),
});
};
interface ProxySettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProxySettingsDialog({
open,
onOpenChange,
}: ProxySettingsDialogProps) {
const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
const { t } = useTranslation();
const schema = useMemo(() => createProxyConfigSchema(t), [t]);
const closePanel = () => onOpenChange(false);
const form = useForm<ProxyConfigForm>({
resolver: zodResolver(schema),
defaultValues: {
listen_address: "127.0.0.1",
listen_port: 5000,
max_retries: 3,
request_timeout: 300,
enable_logging: true,
},
});
// 当配置加载完成后更新表单
useEffect(() => {
if (config) {
form.reset({
...config,
});
}
}, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => {
try {
// 添加 enabled 字段(从当前配置中获取,保持不变)
const configToSave: ProxyConfig = {
...data,
enabled: config?.enabled ?? true,
};
await updateConfig(configToSave);
closePanel();
} catch (error) {
console.error("Save config failed:", error);
}
};
const formId = "proxy-settings-form";
return (
<FullScreenPanel
isOpen={open}
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
onClose={closePanel}
footer={
<>
<Button
type="button"
variant="outline"
onClick={closePanel}
disabled={isUpdating}
>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="submit"
form={formId}
disabled={isUpdating || isLoading}
>
{isUpdating
? t("common.saving", { defaultValue: "保存中..." })
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
</Button>
</>
}
>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("proxy.settings.description", {
defaultValue:
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
})}
</p>
<Alert className="border-emerald-500/40 bg-emerald-500/10">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{t("proxy.settings.alert.autoApply", {
defaultValue:
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
})}
</AlertDescription>
</Alert>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="listen_address"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{ defaultValue: "127.0.0.1" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listen_port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{ defaultValue: "5000" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenPort.description", {
defaultValue:
"代理服务器监听的端口号(1024 ~ 65535",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.advanced.title", {
defaultValue: "高级参数",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.advanced.description", {
defaultValue: "控制请求的稳定性和日志记录。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.maxRetries.label", {
defaultValue: "最大重试次数",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.maxRetries.placeholder",
{ defaultValue: "3" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.maxRetries.description", {
defaultValue: "请求失败时的重试次数(0 ~ 10)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="request_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.requestTimeout.label", {
defaultValue: "请求超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.requestTimeout.placeholder",
{ defaultValue: "0(不限)或 300" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.requestTimeout.description", {
defaultValue:
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="enable_logging"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</section>
</form>
</Form>
</div>
</FullScreenPanel>
);
}
+33 -28
View File
@@ -9,53 +9,57 @@ 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("p-1 rounded-xl transition-all", className)}
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
isActive
? "bg-emerald-500/10 border border-emerald-500/30"
: "bg-muted/50 hover:bg-muted",
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 +68,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 +76,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>
); );
} }
-1
View File
@@ -3,4 +3,3 @@
*/ */
export { ProxyPanel } from "./ProxyPanel"; export { ProxyPanel } from "./ProxyPanel";
export { ProxySettingsDialog } from "./ProxySettingsDialog";
+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>
); );
} }
@@ -93,7 +93,7 @@ export function ImportExportSection({
type="button" type="button"
onClick={onClear} onClick={onClear}
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10" className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
aria-label="Clear selection" aria-label={t("common.clear")}
> >
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
</button> </button>
+117 -69
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { import {
Loader2, Loader2,
Save, Save,
@@ -177,18 +178,17 @@ 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 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);
@@ -214,16 +214,19 @@ export function SettingsPage({
<TabsTrigger value="advanced"> <TabsTrigger value="advanced">
{t("settings.tabAdvanced")} {t("settings.tabAdvanced")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="usage"> <TabsTrigger value="usage">{t("usage.title")}</TabsTrigger>
{t("usage.title", "使用统计")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger> <TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList> </TabsList>
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<TabsContent value="general" className="space-y-6 mt-0"> <TabsContent value="general" className="space-y-6 mt-0">
{settings ? ( {settings ? (
<> <motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<LanguageSettings <LanguageSettings
value={settings.language} value={settings.language}
onChange={(lang) => handleAutoSave({ language: lang })} onChange={(lang) => handleAutoSave({ language: lang })}
@@ -233,13 +236,18 @@ export function SettingsPage({
settings={settings} settings={settings}
onChange={handleAutoSave} onChange={handleAutoSave}
/> />
</> </motion.div>
) : null} ) : null}
</TabsContent> </TabsContent>
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6"> <TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
{settings ? ( {settings ? (
<div className="space-y-4"> <motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<Accordion <Accordion
type="multiple" type="multiple"
defaultValue={[]} defaultValue={[]}
@@ -254,10 +262,10 @@ export function SettingsPage({
<FolderSearch className="h-5 w-5 text-primary" /> <FolderSearch className="h-5 w-5 text-primary" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.configDir.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
ClaudeCodex Gemini {t("settings.advanced.configDir.description")}
</p> </p>
</div> </div>
</div> </div>
@@ -289,10 +297,10 @@ export function SettingsPage({
<Server className="h-5 w-5 text-green-500" /> <Server className="h-5 w-5 text-green-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.proxy.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.proxy.description")}
</p> </p>
</div> </div>
</div> </div>
@@ -307,7 +315,9 @@ export function SettingsPage({
<Activity <Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`} className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/> />
{isRunning ? "运行中" : "已停止"} {isRunning
? t("settings.advanced.proxy.running")
: t("settings.advanced.proxy.stopped")}
</Badge> </Badge>
<Switch <Switch
checked={isRunning} checked={isRunning}
@@ -330,10 +340,10 @@ export function SettingsPage({
<Activity className="h-5 w-5 text-indigo-500" /> <Activity className="h-5 w-5 text-indigo-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.modelTest.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
使 {t("settings.advanced.modelTest.description")}
</p> </p>
</div> </div>
</div> </div>
@@ -345,80 +355,118 @@ export function SettingsPage({
<AccordionItem <AccordionItem
value="failover" value="failover"
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50" className="rounded-xl glass-card overflow-hidden"
> >
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50"> <AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" /> <Activity className="h-5 w-5 text-orange-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.failover.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.failover.description")}
</p> </p>
</div> </div>
</div> </div>
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> </AccordionTrigger>
</AccordionPrimitive.Trigger>
<div className="flex items-center gap-2 pl-4">
<Switch
checked={failoverEnabled}
onCheckedChange={setFailoverEnabled}
/>
</div>
</AccordionPrimitive.Header>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50"> <AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="space-y-6"> <div className="space-y-6">
{/* 故障转移队列管理 */} {/* 代理未运行时的提示 */}
<div className="space-y-4"> {!isRunning && (
<div> <div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<h4 className="text-sm font-semibold"> <p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("proxy.failoverQueue.title", "故障转移队列")} {t("proxy.failover.proxyRequired", {
</h4> defaultValue:
<p className="text-xs text-muted-foreground"> "需要先启动代理服务才能配置故障转移",
{t( })}
"proxy.failoverQueue.description",
"管理各应用的供应商故障转移顺序",
)}
</p> </p>
</div> </div>
)}
{/* 故障转移设置 - 按应用分组 */}
<Tabs defaultValue="claude" className="w-full"> <Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger> <TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger> <TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger> <TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="claude" className="mt-4"> <TabsContent
value="claude"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager <FailoverQueueManager
appType="claude" appType="claude"
disabled={!failoverEnabled} disabled={!isRunning}
/> />
</TabsContent>
<TabsContent value="codex" className="mt-4">
<FailoverQueueManager
appType="codex"
disabled={!failoverEnabled}
/>
</TabsContent>
<TabsContent value="gemini" className="mt-4">
<FailoverQueueManager
appType="gemini"
disabled={!failoverEnabled}
/>
</TabsContent>
</Tabs>
</div> </div>
{/* 熔断器配置 */}
<div className="border-t border-border/50 pt-6"> <div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel <AutoFailoverConfigPanel
enabled={failoverEnabled} appType="claude"
onEnabledChange={setFailoverEnabled} disabled={!isRunning}
/> />
</div> </div>
</TabsContent>
<TabsContent
value="codex"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="codex"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="codex"
disabled={!isRunning}
/>
</div>
</TabsContent>
<TabsContent
value="gemini"
className="mt-4 space-y-6"
>
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold">
{t("proxy.failoverQueue.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("proxy.failoverQueue.description")}
</p>
</div>
<FailoverQueueManager
appType="gemini"
disabled={!isRunning}
/>
</div>
<div className="border-t border-border/50 pt-6">
<AutoFailoverConfigPanel
appType="gemini"
disabled={!isRunning}
/>
</div>
</TabsContent>
</Tabs>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -432,10 +480,10 @@ export function SettingsPage({
<Coins className="h-5 w-5 text-yellow-500" /> <Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.pricing.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
Token {t("settings.advanced.pricing.description")}
</p> </p>
</div> </div>
</div> </div>
@@ -454,10 +502,10 @@ export function SettingsPage({
<Database className="h-5 w-5 text-blue-500" /> <Database className="h-5 w-5 text-blue-500" />
<div className="text-left"> <div className="text-left">
<h3 className="text-base font-semibold"> <h3 className="text-base font-semibold">
{t("settings.advanced.data.title")}
</h3> </h3>
<p className="text-sm text-muted-foreground font-normal"> <p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.data.description")}
</p> </p>
</div> </div>
</div> </div>
@@ -497,7 +545,7 @@ export function SettingsPage({
)} )}
</Button> </Button>
</div> </div>
</div> </motion.div>
) : null} ) : null}
</TabsContent> </TabsContent>
@@ -46,6 +46,14 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onChange({ enableClaudePluginIntegration: value }) onChange({ enableClaudePluginIntegration: value })
} }
/> />
<ToggleRow
icon={<MonitorUp className="h-4 w-4 text-cyan-500" />}
title={t("settings.skipClaudeOnboarding")}
description={t("settings.skipClaudeOnboardingDescription")}
checked={!!settings.skipClaudeOnboarding}
onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}
/>
</div> </div>
</section> </section>
); );
+2 -2
View File
@@ -9,14 +9,14 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root <SwitchPrimitives.Root
ref={ref} ref={ref}
className={cn( className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-300 dark:data-[state=unchecked]:bg-gray-700", "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-900",
className, className,
)} )}
{...props} {...props}
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", "pointer-events-none block h-5 w-5 rounded-full bg-white dark:bg-gray-400 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>
+12 -24
View File
@@ -47,13 +47,11 @@ export function ModelTestConfigPanel() {
try { try {
setIsSaving(true); setIsSaving(true);
await saveStreamCheckConfig(config); await saveStreamCheckConfig(config);
toast.success(t("streamCheck.configSaved", "健康检查配置已保存"), { toast.success(t("streamCheck.configSaved"), {
closeButton: true, closeButton: true,
}); });
} catch (e) { } catch (e) {
toast.error( toast.error(t("streamCheck.configSaveFailed") + ": " + String(e));
t("streamCheck.configSaveFailed", "保存失败") + ": " + String(e),
);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -78,13 +76,11 @@ export function ModelTestConfigPanel() {
{/* 测试模型配置 */} {/* 测试模型配置 */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground"> <h4 className="text-sm font-medium text-muted-foreground">
{t("streamCheck.testModels", "测试模型")} {t("streamCheck.testModels")}
</h4> </h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="claudeModel"> <Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
{t("streamCheck.claudeModel", "Claude 模型")}
</Label>
<Input <Input
id="claudeModel" id="claudeModel"
value={config.claudeModel} value={config.claudeModel}
@@ -96,9 +92,7 @@ export function ModelTestConfigPanel() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="codexModel"> <Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
{t("streamCheck.codexModel", "Codex 模型")}
</Label>
<Input <Input
id="codexModel" id="codexModel"
value={config.codexModel} value={config.codexModel}
@@ -110,9 +104,7 @@ export function ModelTestConfigPanel() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="geminiModel"> <Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
{t("streamCheck.geminiModel", "Gemini 模型")}
</Label>
<Input <Input
id="geminiModel" id="geminiModel"
value={config.geminiModel} value={config.geminiModel}
@@ -128,13 +120,11 @@ export function ModelTestConfigPanel() {
{/* 检查参数配置 */} {/* 检查参数配置 */}
<div className="space-y-4"> <div className="space-y-4">
<h4 className="text-sm font-medium text-muted-foreground"> <h4 className="text-sm font-medium text-muted-foreground">
{t("streamCheck.checkParams", "检查参数")} {t("streamCheck.checkParams")}
</h4> </h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="timeoutSecs"> <Label htmlFor="timeoutSecs">{t("streamCheck.timeout")}</Label>
{t("streamCheck.timeout", "超时时间(秒)")}
</Label>
<Input <Input
id="timeoutSecs" id="timeoutSecs"
type="number" type="number"
@@ -151,9 +141,7 @@ export function ModelTestConfigPanel() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="maxRetries"> <Label htmlFor="maxRetries">{t("streamCheck.maxRetries")}</Label>
{t("streamCheck.maxRetries", "最大重试次数")}
</Label>
<Input <Input
id="maxRetries" id="maxRetries"
type="number" type="number"
@@ -171,7 +159,7 @@ export function ModelTestConfigPanel() {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="degradedThresholdMs"> <Label htmlFor="degradedThresholdMs">
{t("streamCheck.degradedThreshold", "降级阈值(毫秒)")} {t("streamCheck.degradedThreshold")}
</Label> </Label>
<Input <Input
id="degradedThresholdMs" id="degradedThresholdMs"
@@ -196,12 +184,12 @@ export function ModelTestConfigPanel() {
{isSaving ? ( {isSaving ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", "保存中...")} {t("common.saving")}
</> </>
) : ( ) : (
<> <>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
{t("common.save", "保存")} {t("common.save")}
</> </>
)} )}
</Button> </Button>
+20 -31
View File
@@ -63,7 +63,7 @@ export function PricingConfigPanel() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
<CardTitle className="text-base"> <CardTitle className="text-base">
{t("usage.modelPricing", "模型定价")} {t("usage.modelPricing")}
</CardTitle> </CardTitle>
</div> </div>
</CardHeader> </CardHeader>
@@ -85,7 +85,7 @@ export function PricingConfigPanel() {
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
)} )}
<CardTitle className="text-base"> <CardTitle className="text-base">
{t("usage.modelPricing", "模型定价")} {t("usage.modelPricing")}
</CardTitle> </CardTitle>
</div> </div>
</CardHeader> </CardHeader>
@@ -93,8 +93,7 @@ export function PricingConfigPanel() {
<CardContent> <CardContent>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription> <AlertDescription>
{t("usage.loadPricingError", "加载定价数据失败")}:{" "} {t("usage.loadPricingError")}: {String(error)}
{String(error)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</CardContent> </CardContent>
@@ -107,7 +106,7 @@ export function PricingConfigPanel() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium text-muted-foreground"> <h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc", "配置各模型的 Token 成本")} () {t("usage.modelPricingDesc")} {t("usage.perMillion")}
</h4> </h4>
<Button <Button
onClick={(e) => { onClick={(e) => {
@@ -117,41 +116,36 @@ export function PricingConfigPanel() {
size="sm" size="sm"
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
{t("common.add", "新增")} {t("common.add")}
</Button> </Button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{!pricing || pricing.length === 0 ? ( {!pricing || pricing.length === 0 ? (
<Alert> <Alert>
<AlertDescription> <AlertDescription>{t("usage.noPricingData")}</AlertDescription>
{t(
"usage.noPricingData",
'暂无定价数据。点击"新增"添加模型定价配置。',
)}
</AlertDescription>
</Alert> </Alert>
) : ( ) : (
<div className="rounded-md bg-card/60 shadow-sm"> <div className="rounded-md bg-card/60 shadow-sm">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("usage.model", "模型")}</TableHead> <TableHead>{t("usage.model")}</TableHead>
<TableHead>{t("usage.displayName", "显示名称")}</TableHead> <TableHead>{t("usage.displayName")}</TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
{t("usage.inputCost", "输入成本")} {t("usage.inputCost")}
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
{t("usage.outputCost", "输出成本")} {t("usage.outputCost")}
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
{t("usage.cacheReadCost", "缓存读取")} {t("usage.cacheReadCost")}
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
{t("usage.cacheWriteCost", "缓存写入")} {t("usage.cacheWriteCost")}
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
{t("common.actions", "操作")} {t("common.actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -183,7 +177,7 @@ export function PricingConfigPanel() {
setIsAddingNew(false); setIsAddingNew(false);
setEditingModel(model); setEditingModel(model);
}} }}
title={t("common.edit", "编辑")} title={t("common.edit")}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -191,7 +185,7 @@ export function PricingConfigPanel() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setDeleteConfirm(model.modelId)} onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete", "删除")} title={t("common.delete")}
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -223,19 +217,14 @@ export function PricingConfigPanel() {
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{t("usage.deleteConfirmTitle")}</DialogTitle>
{t("usage.deleteConfirmTitle", "确认删除")}
</DialogTitle>
<DialogDescription> <DialogDescription>
{t( {t("usage.deleteConfirmDesc")}
"usage.deleteConfirmDesc",
"确定要删除此模型定价配置吗?此操作无法撤销。",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}> <Button variant="outline" onClick={() => setDeleteConfirm(null)}>
{t("common.cancel", "取消")} {t("common.cancel")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@@ -243,8 +232,8 @@ export function PricingConfigPanel() {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
{deleteMutation.isPending {deleteMutation.isPending
? t("common.deleting", "删除中...") ? t("common.deleting")
: t("common.delete", "删除")} : t("common.delete")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+6 -2
View File
@@ -106,7 +106,9 @@ export function PricingEditModal({
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, modelId: e.target.value }) setFormData({ ...formData, modelId: e.target.value })
} }
placeholder="例如: claude-3-5-sonnet-20241022" placeholder={t("usage.modelIdPlaceholder", {
defaultValue: "例如: claude-3-5-sonnet-20241022",
})}
required required
/> />
</div> </div>
@@ -122,7 +124,9 @@ export function PricingEditModal({
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value }) setFormData({ ...formData, displayName: e.target.value })
} }
placeholder="例如: Claude 3.5 Sonnet" placeholder={t("usage.displayNamePlaceholder", {
defaultValue: "例如: Claude 3.5 Sonnet",
})}
required required
/> />
</div> </div>
+10 -2
View File
@@ -16,8 +16,14 @@ export function RequestDetailPanel({
requestId, requestId,
onClose, onClose,
}: RequestDetailPanelProps) { }: RequestDetailPanelProps) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { data: request, isLoading } = useRequestDetail(requestId); const { data: request, isLoading } = useRequestDetail(requestId);
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
if (isLoading) { if (isLoading) {
return ( return (
@@ -69,7 +75,9 @@ export function RequestDetailPanel({
{t("usage.time", "时间")} {t("usage.time", "时间")}
</dt> </dt>
<dd> <dd>
{new Date(request.createdAt * 1000).toLocaleString("zh-CN")} {new Date(request.createdAt * 1000).toLocaleString(
dateLocale,
)}
</dd> </dd>
</div> </div>
<div> <div>
+35 -27
View File
@@ -23,7 +23,7 @@ import type { LogFilters } from "@/types/usage";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
export function RequestLogTable() { export function RequestLogTable() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// 默认时间范围:过去24小时 // 默认时间范围:过去24小时
@@ -62,6 +62,13 @@ export function RequestLogTable() {
}); });
}; };
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 筛选栏 */} {/* 筛选栏 */}
@@ -77,10 +84,10 @@ export function RequestLogTable() {
} }
> >
<SelectTrigger className="w-[130px] bg-background"> <SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.endpoint", "端点")} /> <SelectValue placeholder={t("usage.appType")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{t("common.all", "全部端点")}</SelectItem> <SelectItem value="all">{t("usage.allApps")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem> <SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem> <SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem> <SelectItem value="gemini">Gemini</SelectItem>
@@ -97,10 +104,10 @@ export function RequestLogTable() {
} }
> >
<SelectTrigger className="w-[130px] bg-background"> <SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.status", "状态码")} /> <SelectValue placeholder={t("usage.statusCode")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{t("common.all", "全部状态")}</SelectItem> <SelectItem value="all">{t("common.all")}</SelectItem>
<SelectItem value="200">200 OK</SelectItem> <SelectItem value="200">200 OK</SelectItem>
<SelectItem value="400">400 Bad Request</SelectItem> <SelectItem value="400">400 Bad Request</SelectItem>
<SelectItem value="401">401 Unauthorized</SelectItem> <SelectItem value="401">401 Unauthorized</SelectItem>
@@ -113,7 +120,7 @@ export function RequestLogTable() {
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder={t("usage.provider", "搜索供应商...")} placeholder={t("usage.searchProviderPlaceholder")}
className="pl-9 bg-background" className="pl-9 bg-background"
value={tempFilters.providerName || ""} value={tempFilters.providerName || ""}
onChange={(e) => onChange={(e) =>
@@ -125,7 +132,7 @@ export function RequestLogTable() {
/> />
</div> </div>
<Input <Input
placeholder={t("usage.model", "搜索模型...")} placeholder={t("usage.searchModelPlaceholder")}
className="w-[180px] bg-background" className="w-[180px] bg-background"
value={tempFilters.model || ""} value={tempFilters.model || ""}
onChange={(e) => onChange={(e) =>
@@ -140,7 +147,7 @@ export function RequestLogTable() {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">:</span> <span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
<Input <Input
type="datetime-local" type="datetime-local"
className="h-8 w-[200px] bg-background" className="h-8 w-[200px] bg-background"
@@ -190,7 +197,7 @@ export function RequestLogTable() {
className="h-8" className="h-8"
> >
<Search className="mr-2 h-3.5 w-3.5" /> <Search className="mr-2 h-3.5 w-3.5" />
{t("common.search", "查询")} {t("common.search")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -199,7 +206,7 @@ export function RequestLogTable() {
className="h-8" className="h-8"
> >
<X className="mr-2 h-3.5 w-3.5" /> <X className="mr-2 h-3.5 w-3.5" />
{t("common.reset", "重置")} {t("common.reset")}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -222,34 +229,34 @@ export function RequestLogTable() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("usage.time", "时间")} {t("usage.time")}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("usage.provider", "供应商")} {t("usage.provider")}
</TableHead> </TableHead>
<TableHead className="min-w-[280px] whitespace-nowrap"> <TableHead className="min-w-[280px] whitespace-nowrap">
{t("usage.billingModel", "计费模型")} {t("usage.billingModel")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("usage.inputTokens", "输入")} {t("usage.inputTokens")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("usage.outputTokens", "输出")} {t("usage.outputTokens")}
</TableHead> </TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap"> <TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheReadTokens", "缓存读取")} {t("usage.cacheReadTokens")}
</TableHead> </TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap"> <TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens", "缓存写入")} {t("usage.cacheCreationTokens")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right whitespace-nowrap">
{t("usage.totalCost", "成本")} {t("usage.totalCost")}
</TableHead> </TableHead>
<TableHead className="text-center min-w-[140px] whitespace-nowrap"> <TableHead className="text-center min-w-[140px] whitespace-nowrap">
{t("usage.timingInfo", "用时/首字")} {t("usage.timingInfo")}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap"> <TableHead className="whitespace-nowrap">
{t("usage.status", "状态")} {t("usage.status")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -260,18 +267,19 @@ export function RequestLogTable() {
colSpan={10} colSpan={10}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
{t("usage.noData", "暂无数据")} {t("usage.noData")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<TableRow key={log.requestId}> <TableRow key={log.requestId}>
<TableCell> <TableCell>
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")} {new Date(log.createdAt * 1000).toLocaleString(
dateLocale,
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{log.providerName || {log.providerName || t("usage.unknownProvider")}
t("usage.unknownProvider", "未知供应商")}
</TableCell> </TableCell>
<TableCell <TableCell
className="font-mono text-sm max-w-[280px] truncate" className="font-mono text-sm max-w-[280px] truncate"
@@ -339,8 +347,8 @@ export function RequestLogTable() {
}`} }`}
> >
{log.isStreaming {log.isStreaming
? t("usage.stream", "流") ? t("usage.stream")
: t("usage.nonStream", "非流")} : t("usage.nonStream")}
</span> </span>
</div> </div>
</TableCell> </TableCell>
@@ -366,7 +374,7 @@ export function RequestLogTable() {
{total > 0 && ( {total > 0 && (
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{t("usage.totalRecords", "共 {{total}} 条记录", { total })} {t("usage.totalRecords", { total })}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
+8 -10
View File
@@ -25,10 +25,8 @@ export function UsageDashboard() {
> >
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold">{t("usage.title", "使用统计")}</h2> <h2 className="text-2xl font-bold">{t("usage.title")}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
{t("usage.subtitle", "查看 AI 模型的使用情况和成本统计")}
</p>
</div> </div>
<Tabs <Tabs
@@ -41,19 +39,19 @@ export function UsageDashboard() {
value="1d" value="1d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors" className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
> >
{t("usage.today", "24小时")} {t("usage.today")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="7d" value="7d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors" className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
> >
{t("usage.last7days", "7天")} {t("usage.last7days")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="30d" value="30d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors" className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
> >
{t("usage.last30days", "30天")} {t("usage.last30days")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
@@ -69,15 +67,15 @@ export function UsageDashboard() {
<TabsList className="bg-muted/50"> <TabsList className="bg-muted/50">
<TabsTrigger value="logs" className="gap-2"> <TabsTrigger value="logs" className="gap-2">
<ListFilter className="h-4 w-4" /> <ListFilter className="h-4 w-4" />
{t("usage.requestLogs", "请求日志")} {t("usage.requestLogs")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="providers" className="gap-2"> <TabsTrigger value="providers" className="gap-2">
<Activity className="h-4 w-4" /> <Activity className="h-4 w-4" />
{t("usage.providerStats", "Provider 统计")} {t("usage.providerStats")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="models" className="gap-2"> <TabsTrigger value="models" className="gap-2">
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
{t("usage.modelStats", "模型统计")} {t("usage.modelStats")}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
+8 -8
View File
@@ -34,7 +34,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
return [ return [
{ {
title: t("usage.totalRequests", "总请求数"), title: t("usage.totalRequests"),
value: totalRequests.toLocaleString(), value: totalRequests.toLocaleString(),
icon: Activity, icon: Activity,
color: "text-blue-500", color: "text-blue-500",
@@ -42,7 +42,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
subValue: null, subValue: null,
}, },
{ {
title: t("usage.totalCost", "总成本"), title: t("usage.totalCost"),
value: `$${totalCost.toFixed(4)}`, value: `$${totalCost.toFixed(4)}`,
icon: DollarSign, icon: DollarSign,
color: "text-green-500", color: "text-green-500",
@@ -50,7 +50,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
subValue: null, subValue: null,
}, },
{ {
title: t("usage.totalTokens", "总 Token 数"), title: t("usage.totalTokens"),
value: totalTokens.toLocaleString(), value: totalTokens.toLocaleString(),
icon: Layers, icon: Layers,
color: "text-purple-500", color: "text-purple-500",
@@ -58,13 +58,13 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
subValue: ( subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50"> <div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>Input</span> <span>{t("usage.input")}</span>
<span className="text-foreground/80"> <span className="text-foreground/80">
{(inputTokens / 1000).toFixed(1)}k {(inputTokens / 1000).toFixed(1)}k
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>Output</span> <span>{t("usage.output")}</span>
<span className="text-foreground/80"> <span className="text-foreground/80">
{(outputTokens / 1000).toFixed(1)}k {(outputTokens / 1000).toFixed(1)}k
</span> </span>
@@ -73,7 +73,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
), ),
}, },
{ {
title: t("usage.cacheTokens", "缓存 Token"), title: t("usage.cacheTokens"),
value: totalCacheTokens.toLocaleString(), value: totalCacheTokens.toLocaleString(),
icon: Database, icon: Database,
color: "text-orange-500", color: "text-orange-500",
@@ -81,13 +81,13 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
subValue: ( subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50"> <div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>Write</span> <span>{t("usage.cacheWrite")}</span>
<span className="text-foreground/80"> <span className="text-foreground/80">
{(cacheWriteTokens / 1000).toFixed(1)}k {(cacheWriteTokens / 1000).toFixed(1)}k
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>Read</span> <span>{t("usage.cacheRead")}</span>
<span className="text-foreground/80"> <span className="text-foreground/80">
{(cacheReadTokens / 1000).toFixed(1)}k {(cacheReadTokens / 1000).toFixed(1)}k
</span> </span>
+9 -3
View File
@@ -17,7 +17,7 @@ interface UsageTrendChartProps {
} }
export function UsageTrendChart({ days }: UsageTrendChartProps) { export function UsageTrendChart({ days }: UsageTrendChartProps) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { data: trends, isLoading } = useUsageTrends(days); const { data: trends, isLoading } = useUsageTrends(days);
if (isLoading) { if (isLoading) {
@@ -29,14 +29,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
} }
const isToday = days === 1; const isToday = days === 1;
const dateLocale =
i18n.language === "zh"
? "zh-CN"
: i18n.language === "ja"
? "ja-JP"
: "en-US";
const chartData = const chartData =
trends?.map((stat) => { trends?.map((stat) => {
const pointDate = new Date(stat.date); const pointDate = new Date(stat.date);
return { return {
rawDate: stat.date, rawDate: stat.date,
label: isToday label: isToday
? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" }) ? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
: pointDate.toLocaleDateString("zh-CN", { : pointDate.toLocaleDateString(dateLocale, {
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
}), }),
+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
View File
@@ -75,6 +75,11 @@ export function useDragSort(providers: Record<string, Provider>, appId: AppId) {
queryKey: ["providers", appId], queryKey: ["providers", appId],
}); });
// 刷新故障转移队列(因为队列顺序依赖 sort_index
await queryClient.invalidateQueries({
queryKey: ["failoverQueue", appId],
});
// 更新托盘菜单以反映新的排序(失败不影响主操作) // 更新托盘菜单以反映新的排序(失败不影响主操作)
try { try {
await providersApi.updateTrayMenu(); await providersApi.updateTrayMenu();
+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",
+8 -2
View File
@@ -5,6 +5,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 type { ProxyConfig } from "@/types/proxy"; import type { ProxyConfig } from "@/types/proxy";
/** /**
@@ -12,6 +13,7 @@ import type { ProxyConfig } from "@/types/proxy";
*/ */
export function useProxyConfig() { export function useProxyConfig() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
// 查询配置 // 查询配置
const { data: config, isLoading } = useQuery({ const { data: config, isLoading } = useQuery({
@@ -24,12 +26,16 @@ export function useProxyConfig() {
mutationFn: (newConfig: ProxyConfig) => mutationFn: (newConfig: ProxyConfig) =>
invoke("update_proxy_config", { config: newConfig }), invoke("update_proxy_config", { config: newConfig }),
onSuccess: () => { onSuccess: () => {
toast.success("代理配置已保存", { closeButton: true }); toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(`保存失败: ${error.message}`); toast.error(
t("proxy.settings.toast.saveFailed", {
error: error.message,
}),
);
}, },
}); });

Some files were not shown because too many files have changed in this diff Show More