From 41267135f5220370fb6de1caab587de61af6e052 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 8 Dec 2025 21:14:06 +0800 Subject: [PATCH] Feat/auto failover (#367) * feat(db): add circuit breaker config table and provider proxy target APIs Add database support for auto-failover feature: - Add circuit_breaker_config table for storing failover thresholds - Add get/update_circuit_breaker_config methods in proxy DAO - Add reset_provider_health method for manual recovery - Add set_proxy_target and get_proxy_targets methods in providers DAO for managing multi-provider failover configuration * feat(proxy): implement circuit breaker and provider router for auto-failover Add core failover logic: - CircuitBreaker: Tracks provider health with three states: - Closed: Normal operation, requests pass through - Open: Circuit broken after consecutive failures, skip provider - HalfOpen: Testing recovery with limited requests - ProviderRouter: Routes requests across multiple providers with: - Health tracking and automatic failover - Configurable failure/success thresholds - Auto-disable proxy target after reaching failure threshold - Support for manual circuit breaker reset - Export new types in proxy module * feat(proxy): add failover Tauri commands and integrate with forwarder Expose failover functionality to frontend: - Add Tauri commands: get_proxy_targets, set_proxy_target, get_provider_health, reset_circuit_breaker, get/update_circuit_breaker_config, get_circuit_breaker_stats - Register all new commands in lib.rs invoke handler - Update forwarder with improved error handling and logging - Integrate ProviderRouter with proxy server startup - Add provider health tracking in request handlers * feat(frontend): add failover API layer and TanStack Query hooks Add frontend data layer for failover management: - Add failover.ts API: Tauri invoke wrappers for all failover commands - Add failover.ts query hooks: TanStack Query mutations and queries - useProxyTargets, useProviderHealth queries - useSetProxyTarget, useResetCircuitBreaker mutations - useCircuitBreakerConfig query and mutation - Update queries.ts with provider health query key - Update mutations.ts to invalidate health on provider changes - Add CircuitBreakerConfig and ProviderHealth types * feat(ui): add auto-failover configuration UI and provider health display Add comprehensive UI for failover management: Components: - ProviderHealthBadge: Display provider health status with color coding - CircuitBreakerConfigPanel: Configure failure/success thresholds, timeout duration, and error rate limits - AutoFailoverConfigPanel: Manage proxy targets with drag-and-drop priority ordering and individual enable/disable controls - ProxyPanel: Integrate failover tabs for unified proxy management Provider enhancements: - ProviderCard: Show health badge and proxy target indicator - ProviderActions: Add "Set as Proxy Target" action - EditProviderDialog: Add is_proxy_target toggle - ProviderList: Support proxy target filtering mode Other: - Update App.tsx routing for settings integration - Update useProviderActions hook with proxy target mutation - Fix ProviderList tests for updated component API * fix(usage): stabilize date range to prevent infinite re-renders * feat(backend): add tool version check command Add get_tool_versions command to check local and latest versions of Claude, Codex, and Gemini CLI tools: - Detect local installed versions via command line execution - Fetch latest versions from npm registry (Claude, Gemini) and GitHub releases API (Codex) - Return comprehensive version info including error details for uninstalled tools - Register command in Tauri invoke handler * style(ui): format accordion component code style Apply consistent code formatting to accordion component: - Convert double quotes to semicolons at line endings - Adjust indentation to 2-space standard - Align with project code style conventions * refactor(providers): update provider card styling to use theme tokens Replace hardcoded color classes with semantic design tokens: - Use bg-card, border-border, text-card-foreground instead of glass-card - Replace gray/white color literals with muted/foreground tokens - Change proxy target indicator color from purple to green - Improve hover states with border-border-active - Ensure consistent dark mode support via CSS variables * refactor(proxy): simplify auto-failover config panel structure Restructure AutoFailoverConfigPanel for better integration: - Remove internal Card wrapper and expansion toggle (now handled by parent) - Extract enabled state to props for external control - Simplify loading state display - Clean up redundant CardHeader/CardContent wrappers - ProxyPanel: reduce complexity by delegating to parent components * feat(settings): enhance settings page with accordion layout and tool versions Major settings page improvements: AboutSection: - Add local tool version detection (Claude, Codex, Gemini) - Display installed vs latest version comparison with visual indicators - Show update availability badges and environment check cards SettingsPage: - Reorganize advanced settings into collapsible accordion sections - Add proxy control panel with inline status toggle - Integrate auto-failover configuration with accordion UI - Add database and cost calculation config sections DirectorySettings & WindowSettings: - Minor styling adjustments for consistency settings.ts API: - Add getToolVersions() wrapper for new backend command * refactor(usage): restructure usage dashboard components Comprehensive usage statistics panel refactoring: UsageDashboard: - Reorganize layout with improved section headers - Add better loading states and empty state handling ModelStatsTable & ProviderStatsTable: - Minor styling updates for consistency ModelTestConfigPanel & PricingConfigPanel: - Simplify component structure - Remove redundant Card wrappers - Improve form field organization RequestLogTable: - Enhance table layout with better column sizing - Improve pagination controls UsageSummaryCards: - Update card styling with semantic tokens - Better responsive grid layout UsageTrendChart: - Refine chart container styling - Improve legend and tooltip display * chore(deps): add accordion and animation dependencies Package updates: - Add @radix-ui/react-accordion for collapsible sections - Add cmdk for command palette support - Add framer-motion for enhanced animations Tailwind config: - Add accordion-up/accordion-down animations - Update darkMode config to support both selector and class - Reorganize color and keyframe definitions for clarity * style(app): update header and app switcher styling App.tsx: - Replace glass-header with explicit bg-background/80 backdrop-blur - Update navigation button container to use bg-muted AppSwitcher: - Replace hardcoded gray colors with semantic muted/foreground tokens - Ensure consistent dark mode support via CSS variables - Add group class for better hover state transitions --- package.json | 3 + pnpm-lock.yaml | 121 +++++++ src-tauri/src/commands/misc.rs | 113 ++++++ src-tauri/src/commands/proxy.rs | 114 ++++++ src-tauri/src/commands/skill.rs | 92 +---- src-tauri/src/database/dao/providers.rs | 110 ++++++ src-tauri/src/database/dao/proxy.rs | 77 ++++ src-tauri/src/database/dao/skills.rs | 26 +- src-tauri/src/database/schema.rs | 110 ++---- src-tauri/src/lib.rs | 12 +- src-tauri/src/proxy/circuit_breaker.rs | 334 +++++++++++++++++ src-tauri/src/proxy/forwarder.rs | 132 +++++-- src-tauri/src/proxy/handlers.rs | 4 + src-tauri/src/proxy/mod.rs | 8 + src-tauri/src/proxy/provider_router.rs | 216 +++++++++++ src-tauri/src/proxy/router.rs | 1 + src-tauri/src/proxy/server.rs | 24 +- src-tauri/src/services/skill.rs | 34 +- src/App.tsx | 16 +- src/components/AppSwitcher.tsx | 24 +- .../providers/EditProviderDialog.tsx | 58 ++- src/components/providers/ProviderActions.tsx | 33 ++ src/components/providers/ProviderCard.tsx | 157 +++++++- .../providers/ProviderEmptyState.tsx | 2 +- .../providers/ProviderHealthBadge.tsx | 70 ++++ src/components/providers/ProviderList.tsx | 42 ++- .../proxy/AutoFailoverConfigPanel.tsx | 336 ++++++++++++++++++ .../proxy/CircuitBreakerConfigPanel.tsx | 208 +++++++++++ src/components/proxy/ProxyPanel.tsx | 236 ++++++++---- src/components/settings/AboutSection.tsx | 144 ++++++-- src/components/settings/DirectorySettings.tsx | 4 +- src/components/settings/SettingsPage.tsx | 283 ++++++++++++--- src/components/settings/WindowSettings.tsx | 75 ++-- src/components/skills/SkillsPage.tsx | 19 +- src/components/ui/accordion.tsx | 56 +++ src/components/usage/ModelStatsTable.tsx | 2 +- src/components/usage/ModelTestConfigPanel.tsx | 270 ++++++-------- src/components/usage/PricingConfigPanel.tsx | 237 ++++++------ src/components/usage/ProviderStatsTable.tsx | 2 +- src/components/usage/RequestLogTable.tsx | 290 ++++++++------- src/components/usage/UsageDashboard.tsx | 113 ++++-- src/components/usage/UsageSummaryCards.tsx | 226 +++++++----- src/components/usage/UsageTrendChart.tsx | 214 ++++++----- src/hooks/useProviderActions.ts | 14 +- src/lib/api/failover.ts | 73 ++++ src/lib/api/settings.ts | 11 + src/lib/api/skills.ts | 26 +- src/lib/query/failover.ts | 116 ++++++ src/lib/query/queries.ts | 10 + src/types/proxy.ts | 33 ++ tailwind.config.js | 277 +++++++++------ tests/components/ProviderList.test.tsx | 3 - 52 files changed, 3916 insertions(+), 1295 deletions(-) create mode 100644 src-tauri/src/proxy/circuit_breaker.rs create mode 100644 src-tauri/src/proxy/provider_router.rs create mode 100644 src/components/providers/ProviderHealthBadge.tsx create mode 100644 src/components/proxy/AutoFailoverConfigPanel.tsx create mode 100644 src/components/proxy/CircuitBreakerConfigPanel.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/lib/api/failover.ts create mode 100644 src/lib/query/failover.ts diff --git a/package.json b/package.json index 33b7e7d0..6cf00b90 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@lobehub/icons-static-svg": "^1.73.0", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -67,7 +68,9 @@ "@tauri-apps/plugin-updater": "^2.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "codemirror": "^6.0.2", + "framer-motion": "^12.23.25", "i18next": "^25.5.2", "jsonc-parser": "^3.2.1", "lucide-react": "^0.542.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 262442bd..10a7ed29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@lobehub/icons-static-svg': specifier: ^1.73.0 version: 1.73.0 + '@radix-ui/react-accordion': + specifier: ^1.2.12 + version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -95,9 +98,15 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) codemirror: specifier: ^6.0.2 version: 6.0.2 + framer-motion: + specifier: ^12.23.25 + version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) i18next: specifier: ^25.5.2 version: 25.5.2(typescript@5.9.2) @@ -652,6 +661,19 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -678,6 +700,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1527,6 +1562,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} @@ -1752,6 +1793,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.23.25: + resolution: {integrity: sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2035,6 +2090,12 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3183,6 +3244,23 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3208,6 +3286,22 @@ snapshots: '@types/react': 18.3.23 '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) @@ -3988,6 +4082,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.18.7 @@ -4202,6 +4308,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fsevents@2.3.3: optional: true @@ -4444,6 +4559,12 @@ snapshots: min-indent@1.0.1: {} + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + ms@2.1.3: {} msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2): diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index dc59acab..6fac6c6c 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -58,3 +58,116 @@ pub async fn get_init_error() -> Result, String> { pub async fn get_migration_result() -> Result { Ok(crate::init_status::take_migration_success()) } + +#[derive(serde::Serialize)] +pub struct ToolVersion { + name: String, + version: Option, + latest_version: Option, // 新增字段:最新版本 + error: Option, +} + +#[tauri::command] +pub async fn get_tool_versions() -> Result, String> { + use std::process::Command; + + let tools = vec!["claude", "codex", "gemini"]; + let mut results = Vec::new(); + + // 用于获取远程版本的 client + let client = reqwest::Client::builder() + .user_agent("cc-switch/1.0") + .build() + .map_err(|e| e.to_string())?; + + for tool in tools { + // 1. 获取本地版本 (保持不变) + let (local_version, local_error) = { + let output = if cfg!(target_os = "windows") { + Command::new("cmd") + .args(["/C", &format!("{tool} --version")]) + .output() + } else { + Command::new("sh") + .arg("-c") + .arg(format!("{tool} --version")) + .output() + }; + + match output { + Ok(out) => { + if out.status.success() { + ( + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()), + None, + ) + } else { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + ( + None, + Some(if err.is_empty() { + "未安装或无法执行".to_string() + } else { + err + }), + ) + } + } + Err(e) => (None, Some(e.to_string())), + } + }; + + // 2. 获取远程最新版本 + let latest_version = match tool { + "claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await, + "codex" => fetch_github_latest_release(&client, "openai/openai-python").await, + "gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, // 修正:使用 npm 官方包 @google/gemini-cli + _ => None, + }; + + results.push(ToolVersion { + name: tool.to_string(), + version: local_version, + latest_version, + error: local_error, + }); + } + + Ok(results) +} + +/// Helper function to fetch latest version from GitHub Release +async fn fetch_github_latest_release(client: &reqwest::Client, repo: &str) -> Option { + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + // GitHub API 需要 user-agent + match client.get(&url).send().await { + Ok(resp) => { + if let Ok(json) = resp.json::().await { + json.get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + None + } + } + Err(_) => None, + } +} + +/// Helper function to fetch latest version from npm registry +async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option { + let url = format!("https://registry.npmjs.org/{package}"); + match client.get(&url).send().await { + Ok(resp) => { + if let Ok(json) = resp.json::().await { + json.get("dist-tags") + .and_then(|tags| tags.get("latest")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + None + } + } + Err(_) => None, + } +} diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 7f30eea0..79a8e49d 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -2,7 +2,9 @@ //! //! 提供前端调用的 API 接口 +use crate::provider::Provider; use crate::proxy::types::*; +use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::store::AppState; /// 启动代理服务器 @@ -45,3 +47,115 @@ pub async fn update_proxy_config( pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result { Ok(state.proxy_service.is_running().await) } + +// ==================== 故障转移相关命令 ==================== + +/// 获取代理目标列表 +#[tauri::command] +pub async fn get_proxy_targets( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result, String> { + let db = &state.db; + db.get_proxy_targets(&app_type) + .await + .map_err(|e| e.to_string()) + .map(|providers| providers.into_values().collect()) +} + +/// 设置代理目标 +#[tauri::command] +pub async fn set_proxy_target( + state: tauri::State<'_, AppState>, + provider_id: String, + app_type: String, + enabled: bool, +) -> Result<(), String> { + let db = &state.db; + + // 设置代理目标状态 + db.set_proxy_target(&provider_id, &app_type, enabled) + .await + .map_err(|e| e.to_string())?; + + // 如果是禁用代理目标,重置健康状态 + if !enabled { + log::info!( + "Resetting health status for provider {provider_id} (app: {app_type}) after disabling proxy target" + ); + if let Err(e) = db.reset_provider_health(&provider_id, &app_type).await { + log::warn!("Failed to reset provider health: {e}"); + } + } + + Ok(()) +} + +/// 获取供应商健康状态 +#[tauri::command] +pub async fn get_provider_health( + state: tauri::State<'_, AppState>, + provider_id: String, + app_type: String, +) -> Result { + let db = &state.db; + db.get_provider_health(&provider_id, &app_type) + .await + .map_err(|e| e.to_string()) +} + +/// 重置熔断器 +#[tauri::command] +pub async fn reset_circuit_breaker( + state: tauri::State<'_, AppState>, + provider_id: String, + app_type: String, +) -> Result<(), String> { + // 重置数据库健康状态 + let db = &state.db; + db.update_provider_health(&provider_id, &app_type, true, None) + .await + .map_err(|e| e.to_string())?; + + // 注意:熔断器状态在内存中,重启代理服务器后会重置 + // 如果代理服务器正在运行,需要通知它重置熔断器 + // 目前先通过数据库重置健康状态,熔断器会在下次超时后自动尝试半开 + + Ok(()) +} + +/// 获取熔断器配置 +#[tauri::command] +pub async fn get_circuit_breaker_config( + state: tauri::State<'_, AppState>, +) -> Result { + let db = &state.db; + db.get_circuit_breaker_config() + .await + .map_err(|e| e.to_string()) +} + +/// 更新熔断器配置 +#[tauri::command] +pub async fn update_circuit_breaker_config( + state: tauri::State<'_, AppState>, + config: CircuitBreakerConfig, +) -> Result<(), String> { + let db = &state.db; + db.update_circuit_breaker_config(&config) + .await + .map_err(|e| e.to_string()) +} + +/// 获取熔断器统计信息(仅当代理服务器运行时) +#[tauri::command] +pub async fn get_circuit_breaker_stats( + state: tauri::State<'_, AppState>, + provider_id: String, + app_type: String, +) -> Result, String> { + // 这个功能需要访问运行中的代理服务器的内存状态 + // 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现 + let _ = (state, provider_id, app_type); + Ok(None) +} diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index e7f51f80..b81c5096 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -1,4 +1,3 @@ -use crate::app_config::AppType; use crate::error::format_skill_error; use crate::services::skill::SkillState; use crate::services::{Skill, SkillRepo, SkillService}; @@ -9,46 +8,15 @@ use tauri::State; pub struct SkillServiceState(pub Arc); -/// 解析 app 参数为 AppType -fn parse_app_type(app: &str) -> Result { - match app.to_lowercase().as_str() { - "claude" => Ok(AppType::Claude), - "codex" => Ok(AppType::Codex), - "gemini" => Ok(AppType::Gemini), - _ => Err(format!("不支持的 app 类型: {app}")), - } -} - -/// 根据 app_type 生成带前缀的 skill key -fn get_skill_key(app_type: &AppType, directory: &str) -> String { - let prefix = match app_type { - AppType::Claude => "claude", - AppType::Codex => "codex", - AppType::Gemini => "gemini", - }; - format!("{prefix}:{directory}") -} - #[tauri::command] pub async fn get_skills( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { - get_skills_for_app("claude".to_string(), service, app_state).await -} - -#[tauri::command] -pub async fn get_skills_for_app( - app: String, - _service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result, String> { - let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; - let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; let skills = service + .0 .list_skills(repos) .await .map_err(|e| e.to_string())?; @@ -58,19 +26,16 @@ pub async fn get_skills_for_app( let existing_states = app_state.db.get_skills().unwrap_or_default(); for skill in &skills { - if skill.installed { - let key = get_skill_key(&app_type, &skill.directory); - if !existing_states.contains_key(&key) { - // 本地有该 skill,但数据库中没有记录,自动添加 - if let Err(e) = app_state.db.update_skill_state( - &key, - &SkillState { - installed: true, - installed_at: Utc::now(), - }, - ) { - log::warn!("同步本地 skill {key} 状态到数据库失败: {e}"); - } + if skill.installed && !existing_states.contains_key(&skill.directory) { + // 本地有该 skill,但数据库中没有记录,自动添加 + if let Err(e) = app_state.db.update_skill_state( + &skill.directory, + &SkillState { + installed: true, + installed_at: Utc::now(), + }, + ) { + log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e); } } } @@ -84,23 +49,11 @@ pub async fn install_skill( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { - install_skill_for_app("claude".to_string(), directory, service, app_state).await -} - -#[tauri::command] -pub async fn install_skill_for_app( - app: String, - directory: String, - _service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result { - let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; - // 先在不持有写锁的情况下收集仓库与技能信息 let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; let skills = service + .0 .list_skills(repos) .await .map_err(|e| e.to_string())?; @@ -140,16 +93,16 @@ pub async fn install_skill_for_app( }; service + .0 .install_skill(directory.clone(), repo) .await .map_err(|e| e.to_string())?; } - let key = get_skill_key(&app_type, &directory); app_state .db .update_skill_state( - &key, + &directory, &SkillState { installed: true, installed_at: Utc::now(), @@ -166,29 +119,16 @@ pub fn uninstall_skill( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { - uninstall_skill_for_app("claude".to_string(), directory, service, app_state) -} - -#[tauri::command] -pub fn uninstall_skill_for_app( - app: String, - directory: String, - _service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result { - let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; - service + .0 .uninstall_skill(directory.clone()) .map_err(|e| e.to_string())?; // Remove from database by setting installed = false - let key = get_skill_key(&app_type, &directory); app_state .db .update_skill_state( - &key, + &directory, &SkillState { installed: false, installed_at: Utc::now(), diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index c2fcc0b5..63ccb70d 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -357,6 +357,116 @@ impl Database { Ok(()) } + /// 设置单个供应商的代理目标状态(支持多个代理目标) + pub async fn set_proxy_target( + &self, + provider_id: &str, + app_type: &str, + enabled: bool, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "UPDATE providers SET is_proxy_target = ?1 + WHERE id = ?2 AND app_type = ?3", + params![if enabled { 1 } else { 0 }, provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 获取指定应用类型的所有代理目标供应商(按 sort_index 排序) + pub async fn get_proxy_targets( + &self, + app_type: &str, + ) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn.prepare( + "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target + FROM providers WHERE app_type = ?1 AND is_proxy_target = 1 + ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let provider_iter = stmt + .query_map(params![app_type], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let settings_config_str: String = row.get(2)?; + let website_url: Option = row.get(3)?; + let category: Option = row.get(4)?; + let created_at: Option = row.get(5)?; + let sort_index: Option = row.get(6)?; + let notes: Option = row.get(7)?; + let icon: Option = row.get(8)?; + let icon_color: Option = row.get(9)?; + let meta_str: String = row.get(10)?; + let is_proxy_target: bool = row.get(11)?; + + 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(); + + Ok(( + id, + Provider { + id: "".to_string(), + name, + settings_config, + website_url, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon, + icon_color, + is_proxy_target: Some(is_proxy_target), + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut providers = IndexMap::new(); + for provider_res in provider_iter { + let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?; + provider.id = id.clone(); + + // 加载 endpoints + let mut stmt_endpoints = conn.prepare( + "SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let endpoints_iter = stmt_endpoints + .query_map(params![id, app_type], |row| { + let url: String = row.get(0)?; + let added_at: Option = row.get(1)?; + Ok(( + url, + crate::settings::CustomEndpoint { + url: "".to_string(), + added_at: added_at.unwrap_or(0), + last_used: None, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut custom_endpoints = HashMap::new(); + for ep_res in endpoints_iter { + let (url, mut ep) = ep_res.map_err(|e| AppError::Database(e.to_string()))?; + ep.url = url.clone(); + custom_endpoints.insert(url, ep); + } + + if let Some(meta) = &mut provider.meta { + meta.custom_endpoints = custom_endpoints; + } + + providers.insert(id, provider); + } + + Ok(providers) + } + /// 获取所有活跃的代理目标 pub fn get_all_proxy_targets(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index 187415f8..0c451512 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -165,6 +165,25 @@ impl Database { Ok(()) } + /// 重置Provider健康状态 + pub async fn reset_provider_health( + &self, + provider_id: &str, + app_type: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2", + rusqlite::params![provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + log::debug!("Reset health status for provider {provider_id} (app: {app_type})"); + + Ok(()) + } + // ==================== Proxy Usage (可选) ==================== /// 记录代理使用统计 @@ -241,4 +260,62 @@ impl Database { Ok(records) } + + // ==================== Circuit Breaker Config ==================== + + /// 获取熔断器配置 + pub async fn get_circuit_breaker_config( + &self, + ) -> Result { + let conn = lock_conn!(self.conn); + + let config = conn + .query_row( + "SELECT failure_threshold, success_threshold, timeout_seconds, + error_rate_threshold, min_requests + FROM circuit_breaker_config WHERE id = 1", + [], + |row| { + Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig { + failure_threshold: row.get::<_, i32>(0)? as u32, + success_threshold: row.get::<_, i32>(1)? as u32, + timeout_seconds: row.get::<_, i64>(2)? as u64, + error_rate_threshold: row.get(3)?, + min_requests: row.get::<_, i32>(4)? as u32, + }) + }, + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(config) + } + + /// 更新熔断器配置 + pub async fn update_circuit_breaker_config( + &self, + config: &crate::proxy::circuit_breaker::CircuitBreakerConfig, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + + conn.execute( + "UPDATE circuit_breaker_config + SET failure_threshold = ?1, + success_threshold = ?2, + timeout_seconds = ?3, + error_rate_threshold = ?4, + min_requests = ?5, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1", + rusqlite::params![ + config.failure_threshold as i32, + config.success_threshold as i32, + config.timeout_seconds as i64, + config.error_rate_threshold, + config.min_requests as i32, + ], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 6727059e..5fc02ee0 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -13,22 +13,18 @@ impl Database { pub fn get_skills(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn - .prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC") + .prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC") .map_err(|e| AppError::Database(e.to_string()))?; let skill_iter = stmt .query_map([], |row| { - let directory: String = row.get(0)?; - let app_type: String = row.get(1)?; - let installed: bool = row.get(2)?; - let installed_at_ts: i64 = row.get(3)?; + let key: String = row.get(0)?; + let installed: bool = row.get(1)?; + let installed_at_ts: i64 = row.get(2)?; let installed_at = chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); - // 构建复合 key:"app_type:directory" - let key = format!("{app_type}:{directory}"); - Ok(( key, SkillState { @@ -48,21 +44,11 @@ impl Database { } /// 更新 Skill 状态 - /// key 格式为 "app_type:directory" pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { - // 解析 key - let (app_type, directory) = if let Some(idx) = key.find(':') { - let (app, dir) = key.split_at(idx); - (app, &dir[1..]) // 跳过冒号 - } else { - // 向后兼容:如果没有前缀,默认为 claude - ("claude", key) - }; - let conn = lock_conn!(self.conn); conn.execute( - "INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", - params![directory, app_type, state.installed, state.installed_at.timestamp()], + "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", + params![key, state.installed, state.installed_at.timestamp()], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 0a4f45cd..14ae7e58 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -96,11 +96,9 @@ impl Database { // 5. Skills 表 conn.execute( "CREATE TABLE IF NOT EXISTS skills ( - directory TEXT NOT NULL, - app_type TEXT NOT NULL, + key TEXT PRIMARY KEY, installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (directory, app_type) + installed_at INTEGER NOT NULL DEFAULT 0 )", [], ) @@ -338,6 +336,28 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; + // 15. Circuit Breaker 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()))?; + Ok(()) } @@ -371,9 +391,7 @@ impl Database { Self::set_user_version(conn, 1)?; } 1 => { - log::info!( - "迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)" - ); + log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)"); Self::migrate_v1_to_v2(conn)?; Self::set_user_version(conn, 2)?; } @@ -462,7 +480,7 @@ impl Database { Ok(()) } - /// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表 + /// v1 -> v2 迁移:添加使用统计表和完整字段 fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> { // providers 表字段 Self::add_column_if_missing( @@ -558,82 +576,6 @@ impl Database { .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?; Self::seed_model_pricing(conn)?; - // 重构 skills 表(添加 app_type 字段) - Self::migrate_skills_table(conn)?; - - Ok(()) - } - - /// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键 - fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> { - // 检查是否已经是新表结构 - if Self::has_column(conn, "skills", "app_type")? { - log::info!("skills 表已经包含 app_type 字段,跳过迁移"); - return Ok(()); - } - - log::info!("开始迁移 skills 表..."); - - // 1. 重命名旧表 - conn.execute("ALTER TABLE skills RENAME TO skills_old", []) - .map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?; - - // 2. 创建新表 - conn.execute( - "CREATE TABLE skills ( - directory TEXT NOT NULL, - app_type TEXT NOT NULL, - installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (directory, app_type) - )", - [], - ) - .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?; - - // 3. 迁移数据:解析 key 格式(如 "claude:my-skill" 或 "codex:foo") - // 旧数据如果没有前缀,默认为 claude - let mut stmt = conn - .prepare("SELECT key, installed, installed_at FROM skills_old") - .map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?; - - let old_skills: Vec<(String, bool, i64)> = stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, bool>(1)?, - row.get::<_, i64>(2)?, - )) - }) - .map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))? - .collect::, _>>() - .map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?; - - let count = old_skills.len(); - - for (key, installed, installed_at) in old_skills { - // 解析 key: "app:directory" 或 "directory"(默认 claude) - let (app_type, directory) = if let Some(idx) = key.find(':') { - let (app, dir) = key.split_at(idx); - (app.to_string(), dir[1..].to_string()) // 跳过冒号 - } else { - ("claude".to_string(), key.clone()) - }; - - conn.execute( - "INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![directory, app_type, installed, installed_at], - ) - .map_err(|e| { - AppError::Database(format!("迁移 skill {key} 到新表失败: {e}")) - })?; - } - - // 4. 删除旧表 - conn.execute("DROP TABLE skills_old", []) - .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?; - - log::info!("skills 表迁移完成,共迁移 {count} 条记录"); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f52c2114..d6a9eabf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -635,11 +635,8 @@ pub fn run() { commands::restore_env_backup, // Skill management commands::get_skills, - commands::get_skills_for_app, commands::install_skill, - commands::install_skill_for_app, commands::uninstall_skill, - commands::uninstall_skill_for_app, commands::get_skill_repos, commands::add_skill_repo, commands::remove_skill_repo, @@ -653,6 +650,14 @@ pub fn run() { commands::get_proxy_config, commands::update_proxy_config, commands::is_proxy_running, + // Proxy failover commands + commands::get_proxy_targets, + commands::set_proxy_target, + commands::get_provider_health, + commands::reset_circuit_breaker, + commands::get_circuit_breaker_config, + commands::update_circuit_breaker_config, + commands::get_circuit_breaker_stats, // Usage statistics commands::get_usage_summary, commands::get_usage_trends, @@ -671,6 +676,7 @@ pub fn run() { commands::save_model_test_config, commands::get_model_test_logs, commands::cleanup_model_test_logs, + commands::get_tool_versions, ]); let app = builder diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs new file mode 100644 index 00000000..acedef87 --- /dev/null +++ b/src-tauri/src/proxy/circuit_breaker.rs @@ -0,0 +1,334 @@ +//! 熔断器模块 +//! +//! 实现熔断器模式,用于防止向不健康的供应商发送请求 + +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; + +/// 熔断器状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CircuitState { + /// 关闭状态 - 正常工作 + Closed, + /// 打开状态 - 熔断激活,拒绝请求 + Open, + /// 半开状态 - 尝试恢复,允许部分请求通过 + HalfOpen, +} + +impl std::fmt::Display for CircuitState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CircuitState::Closed => write!(f, "closed"), + CircuitState::Open => write!(f, "open"), + CircuitState::HalfOpen => write!(f, "half_open"), + } + } +} + +/// 熔断器配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CircuitBreakerConfig { + /// 失败阈值 - 连续失败多少次后打开熔断器 + pub failure_threshold: u32, + /// 成功阈值 - 半开状态下成功多少次后关闭熔断器 + pub success_threshold: u32, + /// 超时时间 - 熔断器打开后多久尝试半开(秒) + pub timeout_seconds: u64, + /// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0) + pub error_rate_threshold: f64, + /// 最小请求数 - 计算错误率前的最小请求数 + pub min_requests: u32, +} + +impl Default for CircuitBreakerConfig { + fn default() -> Self { + Self { + failure_threshold: 5, + success_threshold: 2, + timeout_seconds: 60, + error_rate_threshold: 0.5, + min_requests: 10, + } + } +} + +/// 熔断器实例 +pub struct CircuitBreaker { + /// 当前状态 + state: Arc>, + /// 连续失败计数 + consecutive_failures: Arc, + /// 连续成功计数(半开状态) + consecutive_successes: Arc, + /// 总请求计数 + total_requests: Arc, + /// 失败请求计数 + failed_requests: Arc, + /// 上次打开时间 + last_opened_at: Arc>>, + /// 配置 + config: CircuitBreakerConfig, +} + +impl CircuitBreaker { + /// 创建新的熔断器 + pub fn new(config: CircuitBreakerConfig) -> Self { + Self { + state: Arc::new(RwLock::new(CircuitState::Closed)), + consecutive_failures: Arc::new(AtomicU32::new(0)), + consecutive_successes: Arc::new(AtomicU32::new(0)), + total_requests: Arc::new(AtomicU32::new(0)), + failed_requests: Arc::new(AtomicU32::new(0)), + last_opened_at: Arc::new(RwLock::new(None)), + config, + } + } + + /// 检查是否允许请求通过 + pub async fn allow_request(&self) -> bool { + let state = *self.state.read().await; + + match state { + CircuitState::Closed => true, + CircuitState::Open => { + // 检查是否应该尝试半开 + if let Some(opened_at) = *self.last_opened_at.read().await { + if opened_at.elapsed().as_secs() >= self.config.timeout_seconds { + log::info!( + "Circuit breaker transitioning from Open to HalfOpen (timeout reached)" + ); + self.transition_to_half_open().await; + return true; + } + } + false + } + CircuitState::HalfOpen => true, + } + } + + /// 记录成功 + pub async fn record_success(&self) { + let state = *self.state.read().await; + + // 重置失败计数 + self.consecutive_failures.store(0, Ordering::SeqCst); + self.total_requests.fetch_add(1, Ordering::SeqCst); + + match state { + CircuitState::HalfOpen => { + let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1; + log::debug!( + "Circuit breaker HalfOpen: {} consecutive successes (threshold: {})", + successes, + self.config.success_threshold + ); + + if successes >= self.config.success_threshold { + log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)"); + self.transition_to_closed().await; + } + } + CircuitState::Closed => { + log::debug!("Circuit breaker Closed: request succeeded"); + } + _ => {} + } + } + + /// 记录失败 + pub async fn record_failure(&self) { + let state = *self.state.read().await; + + // 更新计数器 + let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1; + self.total_requests.fetch_add(1, Ordering::SeqCst); + self.failed_requests.fetch_add(1, Ordering::SeqCst); + + // 重置成功计数 + self.consecutive_successes.store(0, Ordering::SeqCst); + + log::debug!( + "Circuit breaker {:?}: {} consecutive failures (threshold: {})", + state, + failures, + self.config.failure_threshold + ); + + // 检查是否应该打开熔断器 + match state { + CircuitState::Closed | CircuitState::HalfOpen => { + // 检查连续失败次数 + if failures >= self.config.failure_threshold { + log::warn!( + "Circuit breaker opening due to {} consecutive failures (threshold: {})", + failures, + self.config.failure_threshold + ); + self.transition_to_open().await; + } else { + // 检查错误率 + let total = self.total_requests.load(Ordering::SeqCst); + let failed = self.failed_requests.load(Ordering::SeqCst); + + if total >= self.config.min_requests { + let error_rate = failed as f64 / total as f64; + log::debug!( + "Circuit breaker error rate: {:.2}% ({}/{} requests)", + error_rate * 100.0, + failed, + total + ); + + if error_rate >= self.config.error_rate_threshold { + log::warn!( + "Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)", + error_rate * 100.0, + self.config.error_rate_threshold * 100.0 + ); + self.transition_to_open().await; + } + } + } + } + _ => {} + } + } + + /// 获取当前状态 + pub async fn get_state(&self) -> CircuitState { + *self.state.read().await + } + + /// 获取统计信息 + #[allow(dead_code)] + pub async fn get_stats(&self) -> CircuitBreakerStats { + CircuitBreakerStats { + state: *self.state.read().await, + consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst), + consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst), + total_requests: self.total_requests.load(Ordering::SeqCst), + failed_requests: self.failed_requests.load(Ordering::SeqCst), + } + } + + /// 重置熔断器(手动恢复) + #[allow(dead_code)] + pub async fn reset(&self) { + log::info!("Circuit breaker manually reset to Closed state"); + self.transition_to_closed().await; + } + + /// 转换到打开状态 + async fn transition_to_open(&self) { + *self.state.write().await = CircuitState::Open; + *self.last_opened_at.write().await = Some(Instant::now()); + self.consecutive_failures.store(0, Ordering::SeqCst); + self.consecutive_successes.store(0, Ordering::SeqCst); + } + + /// 转换到半开状态 + async fn transition_to_half_open(&self) { + *self.state.write().await = CircuitState::HalfOpen; + self.consecutive_successes.store(0, Ordering::SeqCst); + } + + /// 转换到关闭状态 + async fn transition_to_closed(&self) { + *self.state.write().await = CircuitState::Closed; + self.consecutive_failures.store(0, Ordering::SeqCst); + self.consecutive_successes.store(0, Ordering::SeqCst); + // 重置计数器 + self.total_requests.store(0, Ordering::SeqCst); + self.failed_requests.store(0, Ordering::SeqCst); + } +} + +/// 熔断器统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CircuitBreakerStats { + pub state: CircuitState, + pub consecutive_failures: u32, + pub consecutive_successes: u32, + pub total_requests: u32, + pub failed_requests: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_circuit_breaker_closed_to_open() { + let config = CircuitBreakerConfig { + failure_threshold: 3, + ..Default::default() + }; + let breaker = CircuitBreaker::new(config); + + // 初始状态应该是关闭 + assert_eq!(breaker.get_state().await, CircuitState::Closed); + assert!(breaker.allow_request().await); + + // 记录 3 次失败 + for _ in 0..3 { + breaker.record_failure().await; + } + + // 应该转换到打开状态 + assert_eq!(breaker.get_state().await, CircuitState::Open); + assert!(!breaker.allow_request().await); + } + + #[tokio::test] + async fn test_circuit_breaker_half_open_to_closed() { + let config = CircuitBreakerConfig { + failure_threshold: 2, + success_threshold: 2, + ..Default::default() + }; + let breaker = CircuitBreaker::new(config); + + // 打开熔断器 + breaker.record_failure().await; + breaker.record_failure().await; + assert_eq!(breaker.get_state().await, CircuitState::Open); + + // 手动转换到半开状态 + breaker.transition_to_half_open().await; + assert_eq!(breaker.get_state().await, CircuitState::HalfOpen); + + // 记录 2 次成功 + breaker.record_success().await; + breaker.record_success().await; + + // 应该转换到关闭状态 + assert_eq!(breaker.get_state().await, CircuitState::Closed); + } + + #[tokio::test] + async fn test_circuit_breaker_reset() { + let config = CircuitBreakerConfig { + failure_threshold: 2, + ..Default::default() + }; + let breaker = CircuitBreaker::new(config); + + // 打开熔断器 + breaker.record_failure().await; + breaker.record_failure().await; + assert_eq!(breaker.get_state().await, CircuitState::Open); + + // 重置 + breaker.reset().await; + assert_eq!(breaker.get_state().await, CircuitState::Closed); + assert!(breaker.allow_request().await); + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 245759d0..b4226ba0 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -4,8 +4,8 @@ use super::{ error::*, + provider_router::ProviderRouter as NewProviderRouter, providers::{get_adapter, ProviderAdapter}, - router::ProviderRouter, types::ProxyStatus, ProxyError, }; @@ -18,9 +18,11 @@ use tokio::sync::RwLock; pub struct RequestForwarder { client: Client, - router: ProviderRouter, + router: Arc, + #[allow(dead_code)] max_retries: u8, status: Arc>, + current_providers: Arc>>, } impl RequestForwarder { @@ -29,6 +31,7 @@ impl RequestForwarder { timeout_secs: u64, max_retries: u8, status: Arc>, + current_providers: Arc>>, ) -> Self { let mut client_builder = Client::builder(); if timeout_secs > 0 { @@ -41,13 +44,14 @@ impl RequestForwarder { Self { client, - router: ProviderRouter::new(db), + router: Arc::new(NewProviderRouter::new(db)), max_retries, status, + current_providers, } } - /// 转发请求(带重试和故障转移) + /// 转发请求(带故障转移) pub async fn forward_with_retry( &self, app_type: &AppType, @@ -55,21 +59,39 @@ impl RequestForwarder { body: Value, headers: axum::http::HeaderMap, ) -> Result { - let mut failed_ids = Vec::new(); - let mut failover_happened = false; - // 获取适配器 let adapter = get_adapter(app_type); + let app_type_str = app_type.as_str(); - for attempt in 0..self.max_retries { - // 选择Provider - let provider = self.router.select_provider(app_type, &failed_ids).await?; + // 使用新的 ProviderRouter 选择所有可用供应商 + let providers = self + .router + .select_providers(app_type_str) + .await + .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; - log::debug!( - "尝试 {} - 使用Provider: {} ({})", + if providers.is_empty() { + return Err(ProxyError::NoAvailableProvider); + } + + log::info!( + "[{}] 故障转移链: {} 个可用供应商", + app_type_str, + providers.len() + ); + + let mut last_error = None; + let mut failover_happened = false; + + // 依次尝试每个供应商 + for (attempt, provider) in providers.iter().enumerate() { + log::info!( + "[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})", + app_type_str, attempt + 1, + providers.len(), provider.name, - provider.id + provider.sort_index.unwrap_or(999999) ); // 更新状态中的当前Provider信息 @@ -88,16 +110,29 @@ impl RequestForwarder { // 转发请求 match self - .forward(&provider, endpoint, &body, &headers, adapter.as_ref()) + .forward(provider, endpoint, &body, &headers, adapter.as_ref()) .await { Ok(response) => { - let _latency = start.elapsed().as_millis() as u64; + let latency = start.elapsed().as_millis() as u64; - // 成功:更新健康状态 - self.router - .update_health(&provider, app_type, true, None) - .await; + // 成功:记录成功并更新熔断器 + if let Err(e) = self + .router + .record_result(&provider.id, app_type_str, true, None) + .await + { + log::warn!("Failed to record success: {e}"); + } + + // 更新当前应用类型使用的 provider + { + let mut current_providers = self.current_providers.write().await; + current_providers.insert( + app_type_str.to_string(), + (provider.id.clone(), provider.name.clone()), + ); + } // 更新成功统计 { @@ -106,6 +141,12 @@ impl RequestForwarder { status.last_error = None; if failover_happened { status.failover_count += 1; + log::info!( + "[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)", + app_type_str, + provider.name, + latency + ); } // 重新计算成功率 if status.total_requests > 0 { @@ -115,23 +156,33 @@ impl RequestForwarder { } } + log::info!( + "[{}] 请求成功 - Provider: {} - {}ms", + app_type_str, + provider.name, + latency + ); + return Ok(response); } Err(e) => { let latency = start.elapsed().as_millis() as u64; - // 失败:分类错误 + // 失败:记录失败并更新熔断器 + if let Err(record_err) = self + .router + .record_result(&provider.id, app_type_str, false, Some(e.to_string())) + .await + { + log::warn!("Failed to record failure: {record_err}"); + } + + // 分类错误 let category = self.categorize_proxy_error(&e); match category { ErrorCategory::Retryable => { - // 可重试:更新健康状态,添加到失败列表 - self.router - .update_health(&provider, app_type, false, Some(e.to_string())) - .await; - failed_ids.push(provider.id.clone()); - - // 更新错误信息 + // 可重试:更新错误信息,继续尝试下一个供应商 { let mut status = self.status.write().await; status.last_error = @@ -139,15 +190,19 @@ impl RequestForwarder { } log::warn!( - "请求失败(可重试): Provider {} - {} - {}ms", + "[{}] Provider {} 失败(可重试): {} - {}ms", + app_type_str, provider.name, e, latency ); + + last_error = Some(e); + // 继续尝试下一个供应商 continue; } ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => { - // 不可重试:更新失败统计并返回 + // 不可重试:直接返回错误 { let mut status = self.status.write().await; status.failed_requests += 1; @@ -158,7 +213,12 @@ impl RequestForwarder { * 100.0; } } - log::error!("请求失败(不可重试): {e}"); + log::error!( + "[{}] Provider {} 失败(不可重试): {}", + app_type_str, + provider.name, + e + ); return Err(e); } } @@ -166,18 +226,24 @@ impl RequestForwarder { } } - // 所有重试都失败 + // 所有供应商都失败了 { let mut status = self.status.write().await; status.failed_requests += 1; - status.last_error = Some("已达到最大重试次数".to_string()); + status.last_error = Some("所有供应商都失败".to_string()); if status.total_requests > 0 { status.success_rate = (status.success_requests as f32 / status.total_requests as f32) * 100.0; } } - Err(ProxyError::MaxRetriesExceeded) + log::error!( + "[{}] 所有 {} 个供应商都失败了", + app_type_str, + providers.len() + ); + + Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded)) } /// 转发单个请求(使用适配器) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 54ad0cce..679fd2a7 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -322,6 +322,7 @@ pub async fn handle_messages( config.request_timeout, config.max_retries, state.status.clone(), + state.current_providers.clone(), ); let response = forwarder @@ -641,6 +642,7 @@ pub async fn handle_gemini( config.request_timeout, config.max_retries, state.status.clone(), + state.current_providers.clone(), ); // 提取完整的路径和查询参数 @@ -806,6 +808,7 @@ pub async fn handle_responses( config.request_timeout, config.max_retries, state.status.clone(), + state.current_providers.clone(), ); let response = forwarder @@ -985,6 +988,7 @@ pub async fn handle_chat_completions( config.request_timeout, config.max_retries, state.status.clone(), + state.current_providers.clone(), ); let response = forwarder diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index e8390252..c23d277c 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -2,10 +2,12 @@ //! //! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 +pub mod circuit_breaker; pub mod error; mod forwarder; mod handlers; mod health; +pub mod provider_router; pub mod providers; pub mod response_handler; mod router; @@ -16,8 +18,14 @@ pub mod usage; // 公开导出给外部使用(commands, services等模块需要) #[allow(unused_imports)] +pub use circuit_breaker::{ + CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState, +}; +#[allow(unused_imports)] pub use error::ProxyError; #[allow(unused_imports)] +pub use provider_router::ProviderRouter; +#[allow(unused_imports)] pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler}; #[allow(unused_imports)] pub use session::{ClientFormat, ProxySession}; diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs new file mode 100644 index 00000000..9849ecd6 --- /dev/null +++ b/src-tauri/src/proxy/provider_router.rs @@ -0,0 +1,216 @@ +//! 供应商路由器模块 +//! +//! 负责选择和管理代理目标供应商,实现智能故障转移 + +use crate::database::Database; +use crate::error::AppError; +use crate::provider::Provider; +use crate::proxy::circuit_breaker::CircuitBreaker; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// 供应商路由器 +pub struct ProviderRouter { + /// 数据库连接 + db: Arc, + /// 熔断器管理器 - key 格式: "app_type:provider_id" + circuit_breakers: Arc>>>, +} + +impl ProviderRouter { + /// 创建新的供应商路由器 + pub fn new(db: Arc) -> Self { + Self { + db, + circuit_breakers: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// 选择可用的供应商(支持故障转移) + /// 返回按优先级排序的可用供应商列表 + pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { + // 1. 获取所有启用代理的供应商 + let providers = self.db.get_proxy_targets(app_type).await?; + + if providers.is_empty() { + return Err(AppError::Config( + "No proxy target providers configured".to_string(), + )); + } + + log::debug!( + "Found {} proxy target providers for app_type: {}", + providers.len(), + app_type + ); + + // 2. 按 sort_index 排序(已经在数据库查询中排序了) + let sorted_providers: Vec<_> = providers.into_values().collect(); + + // 3. 过滤可用的供应商(检查熔断器状态) + let mut available_providers = Vec::new(); + + for provider in sorted_providers { + let circuit_key = format!("{}:{}", app_type, provider.id); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + + if breaker.allow_request().await { + log::debug!( + "Provider {} is available (circuit state: {:?})", + provider.id, + breaker.get_state().await + ); + available_providers.push(provider); + } else { + log::warn!( + "Provider {} is unavailable (circuit breaker open)", + provider.id + ); + } + } + + if available_providers.is_empty() { + return Err(AppError::Config( + "All proxy target providers are unavailable (circuit breakers open)".to_string(), + )); + } + + log::info!( + "Selected {} available providers for failover chain", + available_providers.len() + ); + + Ok(available_providers) + } + + /// 记录供应商请求结果 + pub async fn record_result( + &self, + provider_id: &str, + app_type: &str, + success: bool, + error_msg: Option, + ) -> Result<(), AppError> { + // 1. 更新熔断器状态 + let circuit_key = format!("{app_type}:{provider_id}"); + let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + + if success { + breaker.record_success().await; + log::debug!("Provider {provider_id} request succeeded"); + } else { + breaker.record_failure().await; + log::warn!( + "Provider {} request failed: {}", + provider_id, + error_msg.as_deref().unwrap_or("Unknown error") + ); + } + + // 2. 更新数据库健康状态 + self.db + .update_provider_health(provider_id, app_type, success, error_msg.clone()) + .await?; + + // 3. 如果连续失败达到熔断阈值,自动禁用代理目标 + if !success { + let health = self.db.get_provider_health(provider_id, app_type).await?; + + // 获取熔断器配置 + let config = self.db.get_circuit_breaker_config().await.ok(); + let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5); + + // 如果连续失败达到阈值,自动关闭该供应商的代理开关 + if health.consecutive_failures >= failure_threshold { + log::warn!( + "Provider {} has failed {} times (threshold: {}), auto-disabling proxy target", + provider_id, + health.consecutive_failures, + failure_threshold + ); + self.db + .set_proxy_target(provider_id, app_type, false) + .await?; + } + } + + Ok(()) + } + + /// 重置熔断器(手动恢复) + #[allow(dead_code)] + pub async fn reset_circuit_breaker(&self, circuit_key: &str) { + let breakers = self.circuit_breakers.read().await; + if let Some(breaker) = breakers.get(circuit_key) { + log::info!("Manually resetting circuit breaker for {circuit_key}"); + breaker.reset().await; + } + } + + /// 获取熔断器状态 + #[allow(dead_code)] + pub async fn get_circuit_breaker_stats( + &self, + provider_id: &str, + app_type: &str, + ) -> Option { + let circuit_key = format!("{app_type}:{provider_id}"); + let breakers = self.circuit_breakers.read().await; + + if let Some(breaker) = breakers.get(&circuit_key) { + Some(breaker.get_stats().await) + } else { + None + } + } + + /// 获取或创建熔断器 + async fn get_or_create_circuit_breaker(&self, key: &str) -> Arc { + // 先尝试读锁获取 + { + let breakers = self.circuit_breakers.read().await; + if let Some(breaker) = breakers.get(key) { + return breaker.clone(); + } + } + + // 如果不存在,获取写锁创建 + let mut breakers = self.circuit_breakers.write().await; + + // 双重检查,防止竞争条件 + if let Some(breaker) = breakers.get(key) { + return breaker.clone(); + } + + // 从数据库加载配置 + let config = self + .db + .get_circuit_breaker_config() + .await + .unwrap_or_default(); + + log::debug!("Creating new circuit breaker for {key} with config: {config:?}"); + + let breaker = Arc::new(CircuitBreaker::new(config)); + breakers.insert(key.to_string(), breaker.clone()); + + breaker + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + + #[tokio::test] + async fn test_provider_router_creation() { + let db = Arc::new(Database::new_in_memory().unwrap()); + let router = ProviderRouter::new(db); + + // 测试创建熔断器 + let breaker = router.get_or_create_circuit_breaker("claude:test").await; + assert!(breaker.allow_request().await); + } +} diff --git a/src-tauri/src/proxy/router.rs b/src-tauri/src/proxy/router.rs index 47efa952..c59c99bf 100644 --- a/src-tauri/src/proxy/router.rs +++ b/src-tauri/src/proxy/router.rs @@ -57,6 +57,7 @@ impl ProviderRouter { } /// 更新Provider健康状态(保留接口但不影响选择) + #[allow(dead_code)] pub async fn update_health( &self, _provider: &Provider, diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 10932c38..6da189c3 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -20,6 +20,8 @@ pub struct ProxyState { pub config: Arc>, pub status: Arc>, pub start_time: Arc>>, + /// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name)) + pub current_providers: Arc>>, } /// 代理HTTP服务器 @@ -36,6 +38,7 @@ impl ProxyServer { config: Arc::new(RwLock::new(config.clone())), status: Arc::new(RwLock::new(ProxyStatus::default())), start_time: Arc::new(RwLock::new(None)), + current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())), }; Self { @@ -121,17 +124,16 @@ impl ProxyServer { status.uptime_seconds = start.elapsed().as_secs(); } - // 获取所有活跃的代理目标 - if let Ok(targets) = self.state.db.get_all_proxy_targets() { - status.active_targets = targets - .into_iter() - .map(|(app_type, name, id)| ActiveTarget { - app_type, - provider_name: name, - provider_id: id, - }) - .collect(); - } + // 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider + let current_providers = self.state.current_providers.read().await; + status.active_targets = current_providers + .iter() + .map(|(app_type, (provider_id, provider_name))| ActiveTarget { + app_type: app_type.clone(), + provider_id: provider_id.clone(), + provider_name: provider_name.clone(), + }) + .collect(); status } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 4285652e..22823f19 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -7,7 +7,6 @@ use std::fs; use std::path::{Path, PathBuf}; use tokio::time::timeout; -use crate::app_config::AppType; use crate::error::format_skill_error; /// 技能对象 @@ -107,16 +106,11 @@ pub struct SkillMetadata { pub struct SkillService { http_client: Client, install_dir: PathBuf, - app_type: AppType, } impl SkillService { pub fn new() -> Result { - Self::new_for_app(AppType::Claude) - } - - pub fn new_for_app(app_type: AppType) -> Result { - let install_dir = Self::get_install_dir_for_app(&app_type)?; + let install_dir = Self::get_install_dir()?; // 确保目录存在 fs::create_dir_all(&install_dir)?; @@ -128,38 +122,16 @@ impl SkillService { .timeout(std::time::Duration::from_secs(10)) .build()?, install_dir, - app_type, }) } - fn get_install_dir_for_app(app_type: &AppType) -> Result { + fn get_install_dir() -> Result { let home = dirs::home_dir().context(format_skill_error( "GET_HOME_DIR_FAILED", &[], Some("checkPermission"), ))?; - - let dir = match app_type { - AppType::Claude => home.join(".claude").join("skills"), - AppType::Codex => { - // 检查是否有自定义 Codex 配置目录 - if let Some(custom) = crate::settings::get_codex_override_dir() { - custom.join("skills") - } else { - home.join(".codex").join("skills") - } - } - AppType::Gemini => { - // 为 Gemini 预留,暂时使用默认路径 - home.join(".gemini").join("skills") - } - }; - - Ok(dir) - } - - pub fn app_type(&self) -> &AppType { - &self.app_type + Ok(home.join(".claude").join("skills")) } } diff --git a/src/App.tsx b/src/App.tsx index c0170db9..05c69520 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { } from "@/lib/api"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; +import { useProxyStatus } from "@/hooks/useProxyStatus"; import { extractErrorMessage } from "@/utils/errorUtils"; import { AppSwitcher } from "@/components/AppSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; @@ -61,7 +62,13 @@ function App() { const addActionButtonClass = "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 { data, isLoading, refetch } = useProvidersQuery(activeApp); + // 获取代理服务状态 + const { isRunning: isProxyRunning } = useProxyStatus(); + + // 获取供应商列表,当代理服务运行时自动刷新 + const { data, isLoading, refetch } = useProvidersQuery(activeApp, { + isProxyRunning, + }); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; // Skills 功能仅支持 Claude 和 Codex @@ -74,7 +81,6 @@ function App() { switchProvider, deleteProvider, saveUsageScript, - setProxyTarget, } = useProviderActions(activeApp); // 监听来自托盘菜单的切换事件 @@ -314,8 +320,8 @@ function App() { currentProviderId={currentProviderId} appId={activeApp} isLoading={isLoading} + isProxyRunning={isProxyRunning} onSwitch={switchProvider} - onSetProxyTarget={setProxyTarget} onEdit={setEditingProvider} onDelete={setConfirmDelete} onDuplicate={handleDuplicateProvider} @@ -369,7 +375,7 @@ function App() { )}
@@ -478,7 +484,7 @@ function App() { <> -
+
{hasSkillsSupport && ( + {/* 重置熔断器按钮 - 代理目标启用时显示 */} + {onResetCircuitBreaker && isProxyTarget && ( + + )} + + +
+ + {/* 说明信息 */} +
+

+ {t("proxy.autoFailover.explanationTitle", "工作原理")} +

+
    +
  • + •{" "} + + {t("proxy.autoFailover.failureThresholdLabel", "失败阈值")} + + : + {t( + "proxy.autoFailover.failureThresholdExplain", + "连续失败达到此次数时,熔断器打开,该供应商暂时不可用", + )} +
  • +
  • + •{" "} + + {t("proxy.autoFailover.timeoutLabel", "恢复等待时间")} + + : + {t( + "proxy.autoFailover.timeoutExplain", + "熔断器打开后,等待此时间后尝试半开状态", + )} +
  • +
  • + •{" "} + + {t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")} + + : + {t( + "proxy.autoFailover.successThresholdExplain", + "半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用", + )} +
  • +
  • + •{" "} + + {t("proxy.autoFailover.errorRateLabel", "错误率阈值")} + + : + {t( + "proxy.autoFailover.errorRateExplain", + "错误率超过此值时,即使未达到失败阈值也会打开熔断器", + )} +
  • +
+
+
+ + ); +} diff --git a/src/components/proxy/CircuitBreakerConfigPanel.tsx b/src/components/proxy/CircuitBreakerConfigPanel.tsx new file mode 100644 index 00000000..3014f532 --- /dev/null +++ b/src/components/proxy/CircuitBreakerConfigPanel.tsx @@ -0,0 +1,208 @@ +import { + useCircuitBreakerConfig, + useUpdateCircuitBreakerConfig, +} from "@/lib/query/failover"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useState, useEffect } from "react"; +import { toast } from "sonner"; + +/** + * 熔断器配置面板 + * 允许用户调整熔断器参数 + */ +export function CircuitBreakerConfigPanel() { + const { data: config, isLoading } = useCircuitBreakerConfig(); + const updateConfig = useUpdateCircuitBreakerConfig(); + + const [formData, setFormData] = useState({ + failureThreshold: 5, + successThreshold: 2, + timeoutSeconds: 60, + errorRateThreshold: 0.5, + minRequests: 10, + }); + + // 当配置加载完成时更新表单数据 + useEffect(() => { + if (config) { + setFormData(config); + } + }, [config]); + + const handleSave = async () => { + try { + await updateConfig.mutateAsync(formData); + toast.success("熔断器配置已保存"); + } catch (error) { + toast.error("保存失败: " + String(error)); + } + }; + + const handleReset = () => { + if (config) { + setFormData(config); + } + }; + + if (isLoading) { + return
加载中...
; + } + + return ( +
+
+

熔断器配置

+

+ 调整熔断器参数以控制故障检测和恢复行为 +

+
+ +
+ +
+ {/* 失败阈值 */} +
+ + + setFormData({ + ...formData, + failureThreshold: parseInt(e.target.value) || 5, + }) + } + /> +

+ 连续失败多少次后打开熔断器 +

+
+ + {/* 超时时间 */} +
+ + + setFormData({ + ...formData, + timeoutSeconds: parseInt(e.target.value) || 60, + }) + } + /> +

+ 熔断器打开后多久尝试恢复(半开状态) +

+
+ + {/* 成功阈值 */} +
+ + + setFormData({ + ...formData, + successThreshold: parseInt(e.target.value) || 2, + }) + } + /> +

+ 半开状态下成功多少次后关闭熔断器 +

+
+ + {/* 错误率阈值 */} +
+ + + setFormData({ + ...formData, + errorRateThreshold: (parseInt(e.target.value) || 50) / 100, + }) + } + /> +

+ 错误率超过此值时打开熔断器 +

+
+ + {/* 最小请求数 */} +
+ + + setFormData({ + ...formData, + minRequests: parseInt(e.target.value) || 10, + }) + } + /> +

+ 计算错误率前的最小请求数 +

+
+
+ +
+ + +
+ + {/* 说明信息 */} +
+

配置说明

+
    +
  • + • 失败阈值:连续失败达到此次数时,熔断器打开 +
  • +
  • + • 超时时间:熔断器打开后,等待此时间后尝试半开 +
  • +
  • + • 成功阈值:半开状态下,成功达到此次数时关闭熔断器 +
  • +
  • + • 错误率阈值:错误率超过此值时,熔断器打开 +
  • +
  • + • 最小请求数:只有请求数达到此值后才计算错误率 +
  • +
+
+
+ ); +} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index b2a8059e..fab2fb54 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -1,27 +1,22 @@ import { useState } from "react"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; +import { Activity, Clock, TrendingUp, Server, ListOrdered } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useProxyStatus } from "@/hooks/useProxyStatus"; -import { Settings, Activity, Clock, TrendingUp, Server } from "lucide-react"; import { ProxySettingsDialog } from "./ProxySettingsDialog"; import { toast } from "sonner"; +import { useProxyTargets } from "@/lib/query/failover"; +import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; +import { useProviderHealth } from "@/lib/query/failover"; +import type { ProxyStatus } from "@/types/proxy"; export function ProxyPanel() { - const { status, isRunning, start, stop, isPending } = useProxyStatus(); + const { status, isRunning } = useProxyStatus(); const [showSettings, setShowSettings] = useState(false); - const handleToggle = async () => { - try { - if (isRunning) { - await stop(); - } else { - await start(); - } - } catch (error) { - console.error("Toggle proxy failed:", error); - } - }; + // 获取所有三个应用类型的代理目标列表 + const { data: claudeTargets = [] } = useProxyTargets("claude"); + const { data: codexTargets = [] } = useProxyTargets("codex"); + const { data: geminiTargets = [] } = useProxyTargets("gemini"); const formatUptime = (seconds: number): string => { const hours = Math.floor(seconds / 3600); @@ -39,58 +34,12 @@ export function ProxyPanel() { return ( <> -
-
-
-
- -
-
-

- 本地代理服务 -

-

- {isRunning - ? `运行中 · ${status?.address}:${status?.port}` - : "已停止"} -

-
-
-
- - - {isRunning ? "运行中" : "已停止"} - - - -
-
- +
{isRunning && status ? (
-
+
-

- 服务地址 -

+

服务地址

http://{status.address}:{status.port} @@ -110,16 +59,14 @@ export function ProxyPanel() {
-
-

- 当前代理 -

+
+

使用中

{status.active_targets && status.active_targets.length > 0 ? (
{status.active_targets.map((target) => (
{target.app_type} @@ -146,6 +93,50 @@ export function ProxyPanel() {

)}
+ + {/* 供应商队列 - 按应用类型分组展示 */} + {(claudeTargets.length > 0 || + codexTargets.length > 0 || + geminiTargets.length > 0) && ( +
+
+ +

+ 故障转移队列 +

+
+ + {/* Claude 队列 */} + {claudeTargets.length > 0 && ( + + )} + + {/* Codex 队列 */} + {codexTargets.length > 0 && ( + + )} + + {/* Gemini 队列 */} + {geminiTargets.length > 0 && ( + + )} +
+ )}
@@ -208,15 +199,114 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) { return (
{icon} - - {label} - + {label}

{value}

); } + +interface ProviderQueueGroupProps { + appType: string; + appLabel: string; + targets: Array<{ + id: string; + name: string; + }>; + status: ProxyStatus; +} + +function ProviderQueueGroup({ + appType, + appLabel, + targets, + status, +}: ProviderQueueGroupProps) { + // 查找该应用类型的当前活跃目标 + const activeTarget = status.active_targets?.find( + (t) => t.app_type === appType, + ); + + return ( +
+ {/* 应用类型标题 */} +
+ + {appLabel} + +
+
+ + {/* 供应商列表 */} +
+ {targets.map((target, index) => ( + + ))} +
+
+ ); +} + +interface ProviderQueueItemProps { + provider: { + id: string; + name: string; + }; + priority: number; + appType: string; + isCurrent: boolean; +} + +function ProviderQueueItem({ + provider, + priority, + appType, + isCurrent, +}: ProviderQueueItemProps) { + const { data: health } = useProviderHealth(provider.id, appType); + + return ( +
+
+ + {priority} + + + {provider.name} + + {isCurrent && ( + + 使用中 + + )} +
+ {/* 健康徽章:队列中的代理目标始终显示,没有健康数据时默认为正常 */} + +
+ ); +} diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index 0011c0b2..ba38430b 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useState } from "react"; -import { Download, ExternalLink, Info, Loader2, RefreshCw } from "lucide-react"; +import { + Download, + ExternalLink, + Info, + Loader2, + RefreshCw, + Terminal, + CheckCircle2, + AlertCircle, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -7,16 +16,27 @@ import { getVersion } from "@tauri-apps/api/app"; import { settingsApi } from "@/lib/api"; import { useUpdate } from "@/contexts/UpdateContext"; import { relaunchApp } from "@/lib/updater"; +import { Badge } from "@/components/ui/badge"; interface AboutSectionProps { isPortable: boolean; } +interface ToolVersion { + name: string; + version: string | null; + latest_version: string | null; + error: string | null; +} + export function AboutSection({ isPortable }: AboutSectionProps) { + // ... (use hooks as before) ... const { t } = useTranslation(); const [version, setVersion] = useState(null); const [isLoadingVersion, setIsLoadingVersion] = useState(true); const [isDownloading, setIsDownloading] = useState(false); + const [toolVersions, setToolVersions] = useState([]); + const [isLoadingTools, setIsLoadingTools] = useState(true); const { hasUpdate, @@ -31,18 +51,24 @@ export function AboutSection({ isPortable }: AboutSectionProps) { let active = true; const load = async () => { try { - const loaded = await getVersion(); + const [appVersion, tools] = await Promise.all([ + getVersion(), + settingsApi.getToolVersions(), + ]); + if (active) { - setVersion(loaded); + setVersion(appVersion); + setToolVersions(tools); } } catch (error) { - console.error("[AboutSection] Failed to get version", error); + console.error("[AboutSection] Failed to load info", error); if (active) { setVersion(null); } } finally { if (active) { setIsLoadingVersion(false); + setIsLoadingTools(false); } } }; @@ -53,6 +79,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) { }; }, []); + // ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ... + const handleOpenReleaseNotes = useCallback(async () => { try { const targetVersion = updateInfo?.availableVersion ?? version ?? ""; @@ -125,7 +153,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) { const displayVersion = version ?? t("common.unknown"); return ( -
+

{t("common.about")}

@@ -133,24 +161,28 @@ export function AboutSection({ isPortable }: AboutSectionProps) {

-
+
-
-

CC Switch

-

- {t("common.version")}{" "} - {isLoadingVersion ? ( - - ) : ( - `v${displayVersion}` +

+

CC Switch

+
+ + + {t("common.version")} + + {isLoadingVersion ? ( + + ) : ( + {`v${displayVersion}`} + )} + + {isPortable && ( + + + {t("settings.portableMode")} + )} -

- {isPortable ? ( -

- - {t("settings.portableMode")} -

- ) : null} +
@@ -159,6 +191,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) { variant="outline" size="sm" onClick={handleOpenReleaseNotes} + className="h-9" > {t("settings.releaseNotes")} @@ -168,7 +201,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) { size="sm" onClick={handleCheckUpdate} disabled={isChecking || isDownloading} - className="min-w-[140px]" + className="min-w-[140px] h-9" > {isDownloading ? ( @@ -194,18 +227,71 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
- {hasUpdate && updateInfo ? ( -
-

+ {hasUpdate && updateInfo && ( +

+

{t("settings.updateAvailable", { version: updateInfo.availableVersion, })}

- {updateInfo.notes ? ( -

{updateInfo.notes}

- ) : null} + {updateInfo.notes && ( +

+ {updateInfo.notes} +

+ )}
- ) : null} + )} +
+ +
+

+ 本地环境检查 +

+
+ {isLoadingTools + ? Array.from({ length: 3 }).map((_, i) => ( +
+ )) + : toolVersions.map((tool) => ( +
+
+
+ + + {tool.name} + +
+ {tool.version ? ( +
+ {tool.latest_version && + tool.version !== tool.latest_version && ( + + Update: {tool.latest_version} + + )} + +
+ ) : ( + + )} +
+
+
+ {tool.version ? tool.version : tool.error || "未安装"} +
+
+
+ ))} +
); diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index 17bb269c..d33b2d77 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -50,7 +50,7 @@ export function DirectorySettings({ onAppConfigChange(event.target.value)} />
- +
) : null} @@ -271,10 +473,7 @@ export function SettingsPage({ open={showRestartPrompt} onOpenChange={(open) => !open && handleRestartLater()} > - + {t("settings.restartRequired")} @@ -287,7 +486,7 @@ export function SettingsPage({ diff --git a/src/components/settings/WindowSettings.tsx b/src/components/settings/WindowSettings.tsx index 06f0d39a..05121ccf 100644 --- a/src/components/settings/WindowSettings.tsx +++ b/src/components/settings/WindowSettings.tsx @@ -1,6 +1,7 @@ import { Switch } from "@/components/ui/switch"; import { useTranslation } from "react-i18next"; import type { SettingsFormState } from "@/hooks/useSettings"; +import { AppWindow, MonitorUp, Power } from "lucide-react"; interface WindowSettingsProps { settings: SettingsFormState; @@ -12,40 +13,46 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) { return (
-
+
+

{t("settings.windowBehavior")}

-

- {t("settings.windowBehaviorHint")} -

-
+
- onChange({ launchOnStartup: value })} - /> +
+ } + title={t("settings.launchOnStartup")} + description={t("settings.launchOnStartupDescription")} + checked={!!settings.launchOnStartup} + onCheckedChange={(value) => onChange({ launchOnStartup: value })} + /> - onChange({ minimizeToTrayOnClose: value })} - /> + } + title={t("settings.minimizeToTray")} + description={t("settings.minimizeToTrayDescription")} + checked={settings.minimizeToTrayOnClose} + onCheckedChange={(value) => + onChange({ minimizeToTrayOnClose: value }) + } + /> - - onChange({ enableClaudePluginIntegration: value }) - } - /> + } + title={t("settings.enableClaudePluginIntegration")} + description={t("settings.enableClaudePluginIntegrationDescription")} + checked={!!settings.enableClaudePluginIntegration} + onCheckedChange={(value) => + onChange({ enableClaudePluginIntegration: value }) + } + /> +
); } interface ToggleRowProps { + icon: React.ReactNode; title: string; description?: string; checked: boolean; @@ -53,18 +60,24 @@ interface ToggleRowProps { } function ToggleRow({ + icon, title, description, checked, onCheckedChange, }: ToggleRowProps) { return ( -
-
-

{title}

- {description ? ( -

{description}

- ) : null} +
+
+
+ {icon} +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
void; - initialApp?: AppType; } export interface SkillsPageHandle { @@ -38,7 +32,7 @@ export interface SkillsPageHandle { } export const SkillsPage = forwardRef( - ({ onClose: _onClose, initialApp = "claude" }, ref) => { + ({ onClose: _onClose }, ref) => { const { t } = useTranslation(); const [skills, setSkills] = useState([]); const [repos, setRepos] = useState([]); @@ -48,13 +42,11 @@ export const SkillsPage = forwardRef( const [filterStatus, setFilterStatus] = useState< "all" | "installed" | "uninstalled" >("all"); - // 使用 initialApp,不允许切换 - const selectedApp = initialApp; const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { try { setLoading(true); - const data = await skillsApi.getAll(selectedApp); + const data = await skillsApi.getAll(); setSkills(data); if (afterLoad) { afterLoad(data); @@ -92,7 +84,6 @@ export const SkillsPage = forwardRef( useEffect(() => { Promise.all([loadSkills(), loadRepos()]); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useImperativeHandle(ref, () => ({ @@ -102,7 +93,7 @@ export const SkillsPage = forwardRef( const handleInstall = async (directory: string) => { try { - await skillsApi.install(directory, selectedApp); + await skillsApi.install(directory); toast.success(t("skills.installSuccess", { name: directory })); await loadSkills(); } catch (error) { @@ -131,7 +122,7 @@ export const SkillsPage = forwardRef( const handleUninstall = async (directory: string) => { try { - await skillsApi.uninstall(directory, selectedApp); + await skillsApi.uninstall(directory); toast.success(t("skills.uninstallSuccess", { name: directory })); await loadSkills(); } catch (error) { diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 00000000..83ff0179 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 331ae8ef..171fffd0 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -18,7 +18,7 @@ export function ModelStatsTable() { } return ( -
+
diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 1d880cdb..4c0677e6 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -1,17 +1,10 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react"; +import { Save, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { getModelTestConfig, @@ -21,7 +14,6 @@ import { export function ModelTestConfigPanel() { const { t } = useTranslation(); - const [isExpanded, setIsExpanded] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -66,160 +58,120 @@ export function ModelTestConfigPanel() { if (isLoading) { return ( - - setIsExpanded(!isExpanded)} - > -
- - - {t("modelTest.configTitle", "模型测试配置")} - -
-
-
+
+ +
); } return ( - - setIsExpanded(!isExpanded)} - > -
- {isExpanded ? ( - - ) : ( - - )} -
- - {t("modelTest.configTitle", "模型测试配置")} - - {!isExpanded && ( - - {t( - "modelTest.configDesc", - "配置模型测试使用的默认模型和提示词", - )} - - )} -
-
-
- - {isExpanded && ( - - {error && ( - - {error} - - )} - -
-
- - - setConfig({ ...config, claudeModel: e.target.value }) - } - placeholder="claude-haiku-4-5-20251001" - /> -
- -
- - - setConfig({ ...config, codexModel: e.target.value }) - } - placeholder="gpt-5.1-low" - /> -
- -
- - - setConfig({ ...config, geminiModel: e.target.value }) - } - placeholder="gemini-3-pro-low" - /> -
-
- -
-
- - - setConfig({ ...config, testPrompt: e.target.value }) - } - placeholder="ping" - /> -

- {t( - "modelTest.testPromptHint", - "发送给模型的测试消息,建议使用简短内容以减少 token 消耗", - )} -

-
- -
- - - setConfig({ - ...config, - timeoutSecs: parseInt(e.target.value) || 15, - }) - } - /> -
-
- -
- -
-
+
+ {error && ( + + {error} + )} - + +
+
+ + + setConfig({ ...config, claudeModel: e.target.value }) + } + placeholder="claude-haiku-4-5-20251001" + /> +
+ +
+ + + setConfig({ ...config, codexModel: e.target.value }) + } + placeholder="gpt-5.1-low" + /> +
+ +
+ + + setConfig({ ...config, geminiModel: e.target.value }) + } + placeholder="gemini-3-pro-low" + /> +
+
+ +
+
+ + + setConfig({ ...config, testPrompt: e.target.value }) + } + placeholder="ping" + /> +

+ {t( + "modelTest.testPromptHint", + "发送给模型的测试消息,建议使用简短内容以减少 token 消耗", + )} +

+
+ +
+ + + setConfig({ + ...config, + timeoutSecs: parseInt(e.target.value) || 15, + }) + } + /> +
+
+ +
+ +
+
); } diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index fb49bf1c..85182221 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -1,12 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, @@ -110,136 +104,107 @@ export function PricingConfigPanel() { } return ( - - setIsExpanded(!isExpanded)} - > -
-
- {isExpanded ? ( - - ) : ( - - )} -
- - {t("usage.modelPricing", "模型定价")} - {pricing && pricing.length > 0 && ( - - ({pricing.length}) - - )} - - {!isExpanded && ( - - {t( - "usage.modelPricingDesc", - "配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)", - )} - - )} -
-
- -
-
+
+
+

+ {t("usage.modelPricingDesc", "配置各模型的 Token 成本")} (每百万) +

+ +
- {isExpanded && ( - - {!pricing || pricing.length === 0 ? ( - - - {t( - "usage.noPricingData", - '暂无定价数据。点击"新增"添加模型定价配置。', - )} - - - ) : ( -
-
- - - {t("usage.model", "模型")} - {t("usage.displayName", "显示名称")} - - {t("usage.inputCost", "输入成本")} - - - {t("usage.outputCost", "输出成本")} - - - {t("usage.cacheReadCost", "缓存读取")} - - - {t("usage.cacheWriteCost", "缓存写入")} - - - {t("common.actions", "操作")} - +
+ {!pricing || pricing.length === 0 ? ( + + + {t( + "usage.noPricingData", + '暂无定价数据。点击"新增"添加模型定价配置。', + )} + + + ) : ( +
+
+ + + {t("usage.model", "模型")} + {t("usage.displayName", "显示名称")} + + {t("usage.inputCost", "输入成本")} + + + {t("usage.outputCost", "输出成本")} + + + {t("usage.cacheReadCost", "缓存读取")} + + + {t("usage.cacheWriteCost", "缓存写入")} + + + {t("common.actions", "操作")} + + + + + {pricing.map((model) => ( + + + {model.modelId} + + {model.displayName} + + ${model.inputCostPerMillion} + + + ${model.outputCostPerMillion} + + + ${model.cacheReadCostPerMillion} + + + ${model.cacheCreationCostPerMillion} + + +
+ + +
+
- - - {pricing.map((model) => ( - - - {model.modelId} - - {model.displayName} - - ${model.inputCostPerMillion} - - - ${model.outputCostPerMillion} - - - ${model.cacheReadCostPerMillion} - - - ${model.cacheCreationCostPerMillion} - - -
- - -
-
-
- ))} -
-
-
- )} - - )} + ))} + + +
+ )} +
{editingModel && ( - +
); } diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index e7233ec6..be6e72f5 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -18,7 +18,7 @@ export function ProviderStatsTable() { } return ( -
+
diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 450509bd..c644b210 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -65,123 +65,151 @@ export function RequestLogTable() { return (
{/* 筛选栏 */} -
- +
+
+ - + - - setTempFilters({ - ...tempFilters, - providerName: e.target.value || undefined, - }) - } - /> +
+
+ + + setTempFilters({ + ...tempFilters, + providerName: e.target.value || undefined, + }) + } + /> +
+ + setTempFilters({ + ...tempFilters, + model: e.target.value || undefined, + }) + } + /> +
+
- - setTempFilters({ - ...tempFilters, - model: e.target.value || undefined, - }) - } - /> +
+
+ 时间范围: + + setTempFilters({ + ...tempFilters, + startDate: e.target.value + ? Math.floor(new Date(e.target.value).getTime() / 1000) + : undefined, + }) + } + /> + - + + setTempFilters({ + ...tempFilters, + endDate: e.target.value + ? Math.floor(new Date(e.target.value).getTime() / 1000) + : undefined, + }) + } + /> +
- - setTempFilters({ - ...tempFilters, - startDate: e.target.value - ? Math.floor(new Date(e.target.value).getTime() / 1000) - : undefined, - }) - } - /> - - - setTempFilters({ - ...tempFilters, - endDate: e.target.value - ? Math.floor(new Date(e.target.value).getTime() / 1000) - : undefined, - }) - } - /> - -
- - - +
+ + + +
@@ -189,40 +217,34 @@ export function RequestLogTable() {
) : ( <> -
+
- - {t("usage.time", "时间")} - - - {t("usage.provider", "供应商")} - - + {t("usage.time", "时间")} + {t("usage.provider", "供应商")} + {t("usage.billingModel", "计费模型")} - + {t("usage.inputTokens", "输入")} - + {t("usage.outputTokens", "输出")} - - {t("usage.cacheReadTokens", "缓存读取")} - - + {t("usage.cacheCreationTokens", "缓存写入")} - + + {t("usage.cacheReadTokens", "缓存读取")} + + {t("usage.totalCost", "成本")} - + {t("usage.timingInfo", "用时/首字")} - - {t("usage.status", "状态")} - + {t("usage.status", "状态")} @@ -258,10 +280,10 @@ export function RequestLogTable() { {log.outputTokens.toLocaleString()} - {log.cacheReadTokens.toLocaleString()} + {log.cacheCreationTokens.toLocaleString()} - {log.cacheCreationTokens.toLocaleString()} + {log.cacheReadTokens.toLocaleString()} ${parseFloat(log.totalCostUsd).toFixed(6)} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index ef63ff0d..d9af2b0c 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -1,13 +1,14 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card } from "@/components/ui/card"; import { UsageSummaryCards } from "./UsageSummaryCards"; import { UsageTrendChart } from "./UsageTrendChart"; import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; import type { TimeRange } from "@/types/usage"; +import { motion } from "framer-motion"; +import { BarChart3, ListFilter, Activity } from "lucide-react"; export function UsageDashboard() { const { t } = useTranslation(); @@ -16,50 +17,90 @@ export function UsageDashboard() { const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; return ( -
-
- + + + {t("usage.today", "24小时")} + + + {t("usage.last7days", "7天")} + + + {t("usage.last30days", "30天")} + + +
- - - + - - - - {t("usage.requestLogs", "请求日志")} - - - {t("usage.providerStats", "Provider 统计")} - - - {t("usage.modelStats", "模型统计")} - - +
+ +
+ + + + {t("usage.requestLogs", "请求日志")} + + + + {t("usage.providerStats", "Provider 统计")} + + + + {t("usage.modelStats", "模型统计")} + + +
- - - + + + + - - - + + + - - - -
-
+ + + + +
+
+ ); } diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index 12dc93d6..01b6dd7b 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -1,6 +1,9 @@ +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { useUsageSummary } from "@/lib/query/usage"; +import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; +import { motion } from "framer-motion"; interface UsageSummaryCardsProps { days: number; @@ -8,29 +11,118 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { const { t } = useTranslation(); - const endDate = Math.floor(Date.now() / 1000); - const startDate = endDate - days * 24 * 60 * 60; + + const { startDate, endDate } = useMemo(() => { + const end = Math.floor(Date.now() / 1000); + const start = end - days * 24 * 60 * 60; + return { startDate: start, endDate: end }; + }, [days]); const { data: summary, isLoading } = useUsageSummary(startDate, endDate); - const totalRequests = summary?.totalRequests ?? 0; - const totalCost = parseFloat(summary?.totalCost || "0").toFixed(4); - const totalInputTokens = summary?.totalInputTokens ?? 0; - const totalOutputTokens = summary?.totalOutputTokens ?? 0; - const totalTokens = totalInputTokens + totalOutputTokens; - const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0; - const cacheReadTokens = summary?.totalCacheReadTokens ?? 0; - const totalCacheTokens = cacheWriteTokens + cacheReadTokens; + + const stats = useMemo(() => { + const totalRequests = summary?.totalRequests ?? 0; + const totalCost = parseFloat(summary?.totalCost || "0"); + + const inputTokens = summary?.totalInputTokens ?? 0; + const outputTokens = summary?.totalOutputTokens ?? 0; + const totalTokens = inputTokens + outputTokens; + + const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0; + const cacheReadTokens = summary?.totalCacheReadTokens ?? 0; + const totalCacheTokens = cacheWriteTokens + cacheReadTokens; + + return [ + { + title: t("usage.totalRequests", "总请求数"), + value: totalRequests.toLocaleString(), + icon: Activity, + color: "text-blue-500", + bg: "bg-blue-500/10", + subValue: null, + }, + { + title: t("usage.totalCost", "总成本"), + value: `$${totalCost.toFixed(4)}`, + icon: DollarSign, + color: "text-green-500", + bg: "bg-green-500/10", + subValue: null, + }, + { + title: t("usage.totalTokens", "总 Token 数"), + value: totalTokens.toLocaleString(), + icon: Layers, + color: "text-purple-500", + bg: "bg-purple-500/10", + subValue: ( +
+
+ Input + + {(inputTokens / 1000).toFixed(1)}k + +
+
+ Output + + {(outputTokens / 1000).toFixed(1)}k + +
+
+ ), + }, + { + title: t("usage.cacheTokens", "缓存 Token"), + value: totalCacheTokens.toLocaleString(), + icon: Database, + color: "text-orange-500", + bg: "bg-orange-500/10", + subValue: ( +
+
+ Write + + {(cacheWriteTokens / 1000).toFixed(1)}k + +
+
+ Read + + {(cacheReadTokens / 1000).toFixed(1)}k + +
+
+ ), + }, + ]; + }, [summary, t]); + + const container = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.1, + }, + }, + }; + + const item = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, + }; if (isLoading) { return (
{[...Array(4)].map((_, i) => ( - - -
- - -
+ + + ))} @@ -39,75 +131,39 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { } return ( -
- - - - {t("usage.totalRequests", "总请求数")} - - - -
- {totalRequests.toLocaleString()} -
-
-
+ + {stats.map((stat, i) => ( + + + +
+

+ {stat.title} +

+
+ +
+
- - - - {t("usage.totalCost", "总成本")} - - - -
${totalCost}
-
-
+
+

+ {stat.value} +

+
- - - - {t("usage.totalTokens", "总 Token 数")} - - - -
- {totalTokens.toLocaleString()} -
-
-
- {t("usage.inputTokens", "输入")}:{" "} - {totalInputTokens.toLocaleString()} -
-
- {t("usage.outputTokens", "输出")}:{" "} - {totalOutputTokens.toLocaleString()} -
-
-
-
- - - - - {t("usage.cacheTokens", "缓存 Token")} - - - -
- {totalCacheTokens.toLocaleString()} -
-
-
- {t("usage.cacheWrite", "写入")}:{" "} - {cacheWriteTokens.toLocaleString()} -
-
- {t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()} -
-
-
-
-
+ {stat.subValue || ( + /* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */ +
+ )} + + + + ))} + ); } diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 2fa2f836..7d82a628 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { - LineChart, - Line, + AreaChart, + Area, XAxis, YAxis, CartesianGrid, @@ -10,6 +10,7 @@ import { Legend, } from "recharts"; import { useUsageTrends } from "@/lib/query/usage"; +import { Loader2 } from "lucide-react"; interface UsageTrendChartProps { days: number; @@ -20,7 +21,11 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { const { data: trends, isLoading } = useUsageTrends(days); if (isLoading) { - return
; + return ( +
+ +
+ ); } const isToday = days === 1; @@ -38,8 +43,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { hour: pointDate.getHours(), inputTokens: stat.totalInputTokens, outputTokens: stat.totalOutputTokens, - cacheCreationTokens: stat.totalCacheCreationTokens, - cacheReadTokens: stat.totalCacheReadTokens, cost: parseFloat(stat.totalCost), }; }) || []; @@ -56,8 +59,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { label: `${hour.toString().padStart(2, "0")}:00`, inputTokens: bucket?.inputTokens ?? 0, outputTokens: bucket?.outputTokens ?? 0, - cacheCreationTokens: bucket?.cacheCreationTokens ?? 0, - cacheReadTokens: bucket?.cacheReadTokens ?? 0, cost: bucket?.cost ?? 0, }; }); @@ -65,96 +66,129 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { const displayData = isToday ? hourlyData : chartData; - const rangeLabel = isToday - ? t("usage.rangeToday", "今天 (按小时)") - : days === 7 - ? t("usage.rangeLast7Days", "过去 7 天") - : t("usage.rangeLast30Days", "过去 30 天"); + const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +
+
+ {entry.name}: + + {entry.name.includes(t("usage.cost", "成本")) + ? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}` + : entry.value.toLocaleString()} + +
+ ))} +
+ ); + } + return null; + }; return ( -
-
+
+

{t("usage.trends", "使用趋势")}

-

{rangeLabel}

+

+ {isToday + ? t("usage.rangeToday", "今天 (按小时)") + : days === 7 + ? t("usage.rangeLast7Days", "过去 7 天") + : t("usage.rangeLast30Days", "过去 30 天")} +

- - - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + + `${(value / 1000).toFixed(0)}k`} + /> + `$${value}`} + /> + } /> + + + + + + +
); } diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 28b98f04..fb683a3f 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -9,7 +9,6 @@ import { useUpdateProviderMutation, useDeleteProviderMutation, useSwitchProviderMutation, - useSetProxyTargetMutation, } from "@/lib/query"; import { extractErrorMessage } from "@/utils/errorUtils"; @@ -25,7 +24,6 @@ export function useProviderActions(activeApp: AppId) { const updateProviderMutation = useUpdateProviderMutation(activeApp); const deleteProviderMutation = useDeleteProviderMutation(activeApp); const switchProviderMutation = useSwitchProviderMutation(activeApp); - const setProxyTargetMutation = useSetProxyTargetMutation(activeApp); // Claude 插件同步逻辑 const syncClaudePlugin = useCallback( @@ -93,14 +91,6 @@ export function useProviderActions(activeApp: AppId) { [switchProviderMutation, syncClaudePlugin], ); - // 设置代理目标 - const setProxyTarget = useCallback( - async (provider: Provider) => { - await setProxyTargetMutation.mutateAsync(provider.id); - }, - [setProxyTargetMutation], - ); - // 删除供应商 const deleteProvider = useCallback( async (id: string) => { @@ -146,14 +136,12 @@ export function useProviderActions(activeApp: AppId) { addProvider, updateProvider, switchProvider, - setProxyTarget, deleteProvider, saveUsageScript, isLoading: addProviderMutation.isPending || updateProviderMutation.isPending || deleteProviderMutation.isPending || - switchProviderMutation.isPending || - setProxyTargetMutation.isPending, + switchProviderMutation.isPending, }; } diff --git a/src/lib/api/failover.ts b/src/lib/api/failover.ts new file mode 100644 index 00000000..aed49451 --- /dev/null +++ b/src/lib/api/failover.ts @@ -0,0 +1,73 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + ProviderHealth, + CircuitBreakerConfig, + CircuitBreakerStats, +} from "@/types/proxy"; + +export interface Provider { + id: string; + name: string; + settingsConfig: unknown; + websiteUrl?: string; + category?: string; + createdAt?: number; + sortIndex?: number; + notes?: string; + meta?: unknown; + icon?: string; + iconColor?: string; + isProxyTarget?: boolean; +} + +export const failoverApi = { + // 获取代理目标列表 + async getProxyTargets(appType: string): Promise { + return invoke("get_proxy_targets", { appType }); + }, + + // 设置代理目标 + async setProxyTarget( + providerId: string, + appType: string, + enabled: boolean, + ): Promise { + return invoke("set_proxy_target", { providerId, appType, enabled }); + }, + + // 获取供应商健康状态 + async getProviderHealth( + providerId: string, + appType: string, + ): Promise { + return invoke("get_provider_health", { providerId, appType }); + }, + + // 重置熔断器 + async resetCircuitBreaker( + providerId: string, + appType: string, + ): Promise { + return invoke("reset_circuit_breaker", { providerId, appType }); + }, + + // 获取熔断器配置 + async getCircuitBreakerConfig(): Promise { + return invoke("get_circuit_breaker_config"); + }, + + // 更新熔断器配置 + async updateCircuitBreakerConfig( + config: CircuitBreakerConfig, + ): Promise { + return invoke("update_circuit_breaker_config", { config }); + }, + + // 获取熔断器统计信息 + async getCircuitBreakerStats( + providerId: string, + appType: string, + ): Promise { + return invoke("get_circuit_breaker_stats", { providerId, appType }); + }, +}; diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 8922d8b5..4a8035ef 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -115,4 +115,15 @@ export const settingsApi = { async getAutoLaunchStatus(): Promise { return await invoke("get_auto_launch_status"); }, + + async getToolVersions(): Promise< + Array<{ + name: string; + version: string | null; + latest_version: string | null; + error: string | null; + }> + > { + return await invoke("get_tool_versions"); + }, }; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 862813a2..372baf90 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -19,31 +19,17 @@ export interface SkillRepo { enabled: boolean; } -export type AppType = "claude" | "codex" | "gemini"; - export const skillsApi = { - async getAll(app: AppType = "claude"): Promise { - if (app === "claude") { - return await invoke("get_skills"); - } - return await invoke("get_skills_for_app", { app }); + async getAll(): Promise { + return await invoke("get_skills"); }, - async install(directory: string, app: AppType = "claude"): Promise { - if (app === "claude") { - return await invoke("install_skill", { directory }); - } - return await invoke("install_skill_for_app", { app, directory }); + async install(directory: string): Promise { + return await invoke("install_skill", { directory }); }, - async uninstall( - directory: string, - app: AppType = "claude", - ): Promise { - if (app === "claude") { - return await invoke("uninstall_skill", { directory }); - } - return await invoke("uninstall_skill_for_app", { app, directory }); + async uninstall(directory: string): Promise { + return await invoke("uninstall_skill", { directory }); }, async getRepos(): Promise { diff --git a/src/lib/query/failover.ts b/src/lib/query/failover.ts new file mode 100644 index 00000000..7c9aea6c --- /dev/null +++ b/src/lib/query/failover.ts @@ -0,0 +1,116 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { failoverApi } from "@/lib/api/failover"; + +/** + * 获取代理目标列表 + */ +export function useProxyTargets(appType: string) { + return useQuery({ + queryKey: ["proxyTargets", appType], + queryFn: () => failoverApi.getProxyTargets(appType), + enabled: !!appType, + }); +} + +/** + * 获取供应商健康状态 + */ +export function useProviderHealth(providerId: string, appType: string) { + return useQuery({ + queryKey: ["providerHealth", providerId, appType], + queryFn: () => failoverApi.getProviderHealth(providerId, appType), + enabled: !!providerId && !!appType, + refetchInterval: 5000, // 每 5 秒刷新一次 + retry: false, + }); +} + +/** + * 设置代理目标 + */ +export function useSetProxyTarget() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + providerId, + appType, + enabled, + }: { + providerId: string; + appType: string; + enabled: boolean; + }) => failoverApi.setProxyTarget(providerId, appType, enabled), + onSuccess: (_, variables) => { + // 刷新代理目标列表 + queryClient.invalidateQueries({ + queryKey: ["proxyTargets", variables.appType], + }); + // 刷新供应商列表 + queryClient.invalidateQueries({ queryKey: ["providers"] }); + // 刷新健康状态 + queryClient.invalidateQueries({ + queryKey: ["providerHealth", variables.providerId, variables.appType], + }); + }, + }); +} + +/** + * 重置熔断器 + */ +export function useResetCircuitBreaker() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + providerId, + appType, + }: { + providerId: string; + appType: string; + }) => failoverApi.resetCircuitBreaker(providerId, appType), + onSuccess: (_, variables) => { + // 刷新健康状态 + queryClient.invalidateQueries({ + queryKey: ["providerHealth", variables.providerId, variables.appType], + }); + }, + }); +} + +/** + * 获取熔断器配置 + */ +export function useCircuitBreakerConfig() { + return useQuery({ + queryKey: ["circuitBreakerConfig"], + queryFn: () => failoverApi.getCircuitBreakerConfig(), + }); +} + +/** + * 更新熔断器配置 + */ +export function useUpdateCircuitBreakerConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: failoverApi.updateCircuitBreakerConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["circuitBreakerConfig"] }); + }, + }); +} + +/** + * 获取熔断器统计信息 + */ +export function useCircuitBreakerStats(providerId: string, appType: string) { + return useQuery({ + queryKey: ["circuitBreakerStats", providerId, appType], + queryFn: () => failoverApi.getCircuitBreakerStats(providerId, appType), + enabled: !!providerId && !!appType, + refetchInterval: 5000, // 每 5 秒刷新一次 + }); +} diff --git a/src/lib/query/queries.ts b/src/lib/query/queries.ts index e1beb6ae..47a40416 100644 --- a/src/lib/query/queries.ts +++ b/src/lib/query/queries.ts @@ -34,12 +34,22 @@ export interface ProvidersQueryData { currentProviderId: string; } +export interface UseProvidersQueryOptions { + isProxyRunning?: boolean; // 代理服务是否运行中 +} + export const useProvidersQuery = ( appId: AppId, + options?: UseProvidersQueryOptions, ): UseQueryResult => { + const { isProxyRunning = false } = options || {}; + return useQuery({ queryKey: ["providers", appId], placeholderData: keepPreviousData, + // 当代理服务运行时,每 10 秒刷新一次供应商列表 + // 这样可以自动反映后端熔断器自动禁用代理目标的变更 + refetchInterval: isProxyRunning ? 10000 : false, queryFn: async () => { let providers: Record = {}; let currentProviderId = ""; diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 83762c41..8d4d4dc5 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -48,6 +48,39 @@ export interface ProviderHealth { updated_at: string; } +// 熔断器相关类型 +export interface CircuitBreakerConfig { + failureThreshold: number; + successThreshold: number; + timeoutSeconds: number; + errorRateThreshold: number; + minRequests: number; +} + +export type CircuitState = "closed" | "open" | "half_open"; + +export interface CircuitBreakerStats { + state: CircuitState; + consecutiveFailures: number; + consecutiveSuccesses: number; + totalRequests: number; + failedRequests: number; +} + +// 供应商健康状态枚举 +export enum ProviderHealthStatus { + Healthy = "healthy", + Degraded = "degraded", + Failed = "failed", + Unknown = "unknown", +} + +// 扩展 ProviderHealth 以包含前端计算的状态 +export interface ProviderHealthWithStatus extends ProviderHealth { + status: ProviderHealthStatus; + circuitState?: CircuitState; +} + export interface ProxyUsageRecord { provider_id: string; app_type: string; diff --git a/tailwind.config.js b/tailwind.config.js index 19715f82..f433aba8 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,119 +4,172 @@ export default { "./src/index.html", "./src/**/*.{js,ts,jsx,tsx}", ], - darkMode: "selector", + darkMode: ["selector", "class"], theme: { - extend: { - colors: { - // shadcn/ui CSS 变量映射 - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - // macOS 风格系统蓝 - blue: { - 400: '#409CFF', - 500: '#0A84FF', - 600: '#0060DF', - }, - // 自定义灰色系列(对齐 macOS 深色 System Gray) - gray: { - 50: '#fafafa', // bg-primary - 100: '#f4f4f5', // bg-tertiary - 200: '#e4e4e7', // border - 300: '#d4d4d8', // border-hover - 400: '#a1a1aa', // text-tertiary - 500: '#71717a', // text-secondary - 600: '#636366', // text-secondary-dark / systemGray2 - 700: '#48484A', // bg-tertiary-dark / separators - 800: '#3A3A3C', // bg-secondary-dark - 900: '#2C2C2E', // header / modal bg - 950: '#1C1C1E', // app main bg - }, - // 状态颜色 - green: { - 500: '#10b981', - 100: '#d1fae5', - }, - red: { - 500: '#ef4444', - 100: '#fee2e2', - }, - amber: { - 500: '#f59e0b', - 100: '#fef3c7', - }, - }, - boxShadow: { - 'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)', - 'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', - 'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', - }, - borderRadius: { - 'sm': '0.375rem', - 'md': '0.5rem', - 'lg': '0.75rem', - 'xl': '0.875rem', - }, - fontFamily: { - sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'], - mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'], - }, - animation: { - 'fade-in': 'fadeIn 0.5s ease-out', - 'slide-up': 'slideUp 0.5s ease-out', - 'slide-down': 'slideDown 0.3s ease-out', - 'slide-in-right': 'slideInRight 0.3s ease-out', - 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', - }, - keyframes: { - fadeIn: { - '0%': { opacity: '0' }, - '100%': { opacity: '1' }, - }, - slideUp: { - '0%': { transform: 'translateY(20px)', opacity: '0' }, - '100%': { transform: 'translateY(0)', opacity: '1' }, - }, - slideDown: { - '0%': { transform: 'translateY(-100%)', opacity: '0' }, - '100%': { transform: 'translateY(0)', opacity: '1' }, - }, - slideInRight: { - '0%': { transform: 'translateX(100%)', opacity: '0' }, - '100%': { transform: 'translateX(0)', opacity: '1' }, - } - } - }, + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + blue: { + '400': '#409CFF', + '500': '#0A84FF', + '600': '#0060DF' + }, + gray: { + '50': '#fafafa', + '100': '#f4f4f5', + '200': '#e4e4e7', + '300': '#d4d4d8', + '400': '#a1a1aa', + '500': '#71717a', + '600': '#636366', + '700': '#48484A', + '800': '#3A3A3C', + '900': '#2C2C2E', + '950': '#1C1C1E' + }, + green: { + '100': '#d1fae5', + '500': '#10b981' + }, + red: { + '100': '#fee2e2', + '500': '#ef4444' + }, + amber: { + '100': '#fef3c7', + '500': '#f59e0b' + } + }, + boxShadow: { + sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', + md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)' + }, + borderRadius: { + sm: '0.375rem', + md: '0.5rem', + lg: '0.75rem', + xl: '0.875rem' + }, + fontFamily: { + // 使用与之前版本保持一致的系统字体栈 + sans: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + ], + mono: [ + 'ui-monospace', + 'SFMono-Regular', + '"SF Mono"', + 'Consolas', + '"Liberation Mono"', + 'Menlo', + 'monospace', + ], + }, + animation: { + 'fade-in': 'fadeIn 0.5s ease-out', + 'slide-up': 'slideUp 0.5s ease-out', + 'slide-down': 'slideDown 0.3s ease-out', + 'slide-in-right': 'slideInRight 0.3s ease-out', + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + }, + keyframes: { + fadeIn: { + '0%': { + opacity: '0' + }, + '100%': { + opacity: '1' + } + }, + slideUp: { + '0%': { + transform: 'translateY(20px)', + opacity: '0' + }, + '100%': { + transform: 'translateY(0)', + opacity: '1' + } + }, + slideDown: { + '0%': { + transform: 'translateY(-100%)', + opacity: '0' + }, + '100%': { + transform: 'translateY(0)', + opacity: '1' + } + }, + slideInRight: { + '0%': { + transform: 'translateX(100%)', + opacity: '0' + }, + '100%': { + transform: 'translateX(0)', + opacity: '1' + } + }, + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + } + } }, plugins: [], } diff --git a/tests/components/ProviderList.test.tsx b/tests/components/ProviderList.test.tsx index b53dd52e..4c0a1688 100644 --- a/tests/components/ProviderList.test.tsx +++ b/tests/components/ProviderList.test.tsx @@ -125,7 +125,6 @@ describe("ProviderList Component", () => { onDelete={vi.fn()} onDuplicate={vi.fn()} onOpenWebsite={vi.fn()} - onSetProxyTarget={vi.fn()} isLoading />, ); @@ -154,7 +153,6 @@ describe("ProviderList Component", () => { onDelete={vi.fn()} onDuplicate={vi.fn()} onOpenWebsite={vi.fn()} - onSetProxyTarget={vi.fn()} onCreate={handleCreate} />, ); @@ -195,7 +193,6 @@ describe("ProviderList Component", () => { onDuplicate={handleDuplicate} onConfigureUsage={handleUsage} onOpenWebsite={handleOpenWebsite} - onSetProxyTarget={vi.fn()} />, );