Compare commits

...

15 Commits

Author SHA1 Message Date
YoVinchen c39ce857ab Merge branch 'main' into feat/auto-failover
# Conflicts:
#	src/App.tsx
2025-12-08 21:12:19 +08:00
YoVinchen 26acb5f7c0 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
2025-12-08 17:12:15 +08:00
YoVinchen d403a28c73 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
2025-12-08 17:10:52 +08:00
YoVinchen a8f9562758 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
2025-12-08 17:08:55 +08:00
YoVinchen d83312ad3f 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
2025-12-08 17:05:48 +08:00
YoVinchen cfbbc9485f 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
2025-12-08 17:03:07 +08:00
YoVinchen 4c884a1256 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
2025-12-08 16:53:36 +08:00
YoVinchen 0210e7991f 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
2025-12-08 16:48:39 +08:00
YoVinchen ddc027d854 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
2025-12-08 16:47:34 +08:00
YoVinchen afec14b913 fix(usage): stabilize date range to prevent infinite re-renders 2025-12-08 14:28:50 +08:00
YoVinchen 94da8ca89d 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
2025-12-08 11:27:35 +08:00
YoVinchen e093164b8d 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
2025-12-08 11:09:19 +08:00
YoVinchen d57da0b700 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
2025-12-08 11:02:47 +08:00
YoVinchen 65a76a7ce4 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
2025-12-08 10:58:36 +08:00
YoVinchen 2a4fcc46ff 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
2025-12-08 10:56:31 +08:00
52 changed files with 3916 additions and 1295 deletions
+3
View File
@@ -50,6 +50,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0", "@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -67,7 +68,9 @@
"@tauri-apps/plugin-updater": "^2.0.0", "@tauri-apps/plugin-updater": "^2.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2", "i18next": "^25.5.2",
"jsonc-parser": "^3.2.1", "jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
+121
View File
@@ -44,6 +44,9 @@ importers:
'@lobehub/icons-static-svg': '@lobehub/icons-static-svg':
specifier: ^1.73.0 specifier: ^1.73.0
version: 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': '@radix-ui/react-checkbox':
specifier: ^1.3.3 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) 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: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 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: codemirror:
specifier: ^6.0.2 specifier: ^6.0.2
version: 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: i18next:
specifier: ^25.5.2 specifier: ^25.5.2
version: 25.5.2(typescript@5.9.2) version: 25.5.2(typescript@5.9.2)
@@ -652,6 +661,19 @@ packages:
'@radix-ui/primitive@1.1.3': '@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} 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': '@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies: peerDependencies:
@@ -678,6 +700,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true 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': '@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies: peerDependencies:
@@ -1527,6 +1562,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} 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: codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
@@ -1752,6 +1793,20 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} 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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2035,6 +2090,12 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'} 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: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3183,6 +3244,23 @@ snapshots:
'@radix-ui/primitive@1.1.3': {} '@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)': '@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: 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) '@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': 18.3.23
'@types/react-dom': 18.3.7(@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)': '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) '@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: {} 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: codemirror@6.0.2:
dependencies: dependencies:
'@codemirror/autocomplete': 6.18.7 '@codemirror/autocomplete': 6.18.7
@@ -4202,6 +4308,15 @@ snapshots:
fraction.js@5.3.4: {} 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: fsevents@2.3.3:
optional: true optional: true
@@ -4444,6 +4559,12 @@ snapshots:
min-indent@1.0.1: {} 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: {} ms@2.1.3: {}
msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2): msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2):
+113
View File
@@ -58,3 +58,116 @@ pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
pub async fn get_migration_result() -> Result<bool, String> { pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success()) Ok(crate::init_status::take_migration_success())
} }
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
version: Option<String>,
latest_version: Option<String>, // 新增字段:最新版本
error: Option<String>,
}
#[tauri::command]
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, 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<String> {
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::<serde_json::Value>().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<String> {
let url = format!("https://registry.npmjs.org/{package}");
match client.get(&url).send().await {
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().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,
}
}
+114
View File
@@ -2,7 +2,9 @@
//! //!
//! 提供前端调用的 API 接口 //! 提供前端调用的 API 接口
use crate::provider::Provider;
use crate::proxy::types::*; use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState; 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<bool, String> { pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
Ok(state.proxy_service.is_running().await) Ok(state.proxy_service.is_running().await)
} }
// ==================== 故障转移相关命令 ====================
/// 获取代理目标列表
#[tauri::command]
pub async fn get_proxy_targets(
state: tauri::State<'_, AppState>,
app_type: String,
) -> Result<Vec<Provider>, 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<ProviderHealth, String> {
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<CircuitBreakerConfig, String> {
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<Option<CircuitBreakerStats>, String> {
// 这个功能需要访问运行中的代理服务器的内存状态
// 目前先返回 None,后续可以通过 ProxyService 暴露接口来实现
let _ = (state, provider_id, app_type);
Ok(None)
}
+16 -76
View File
@@ -1,4 +1,3 @@
use crate::app_config::AppType;
use crate::error::format_skill_error; use crate::error::format_skill_error;
use crate::services::skill::SkillState; use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService}; use crate::services::{Skill, SkillRepo, SkillService};
@@ -9,46 +8,15 @@ use tauri::State;
pub struct SkillServiceState(pub Arc<SkillService>); pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
fn parse_app_type(app: &str) -> Result<AppType, String> {
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] #[tauri::command]
pub async fn get_skills( pub async fn get_skills(
service: State<'_, SkillServiceState>, service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> { ) -> Result<Vec<Skill>, 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<Vec<Skill>, 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 repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service let skills = service
.0
.list_skills(repos) .list_skills(repos)
.await .await
.map_err(|e| e.to_string())?; .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(); let existing_states = app_state.db.get_skills().unwrap_or_default();
for skill in &skills { for skill in &skills {
if skill.installed { if skill.installed && !existing_states.contains_key(&skill.directory) {
let key = get_skill_key(&app_type, &skill.directory); // 本地有该 skill,但数据库中没有记录,自动添加
if !existing_states.contains_key(&key) { if let Err(e) = app_state.db.update_skill_state(
// 本地有该 skill,但数据库中没有记录,自动添加 &skill.directory,
if let Err(e) = app_state.db.update_skill_state( &SkillState {
&key, installed: true,
&SkillState { installed_at: Utc::now(),
installed: true, },
installed_at: Utc::now(), ) {
}, log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
) {
log::warn!("同步本地 skill {key} 状态到数据库失败: {e}");
}
} }
} }
} }
@@ -84,23 +49,11 @@ pub async fn install_skill(
service: State<'_, SkillServiceState>, service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
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<bool, 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 repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service let skills = service
.0
.list_skills(repos) .list_skills(repos)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -140,16 +93,16 @@ pub async fn install_skill_for_app(
}; };
service service
.0
.install_skill(directory.clone(), repo) .install_skill(directory.clone(), repo)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
let key = get_skill_key(&app_type, &directory);
app_state app_state
.db .db
.update_skill_state( .update_skill_state(
&key, &directory,
&SkillState { &SkillState {
installed: true, installed: true,
installed_at: Utc::now(), installed_at: Utc::now(),
@@ -166,29 +119,16 @@ pub fn uninstall_skill(
service: State<'_, SkillServiceState>, service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
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<bool, String> {
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
service service
.0
.uninstall_skill(directory.clone()) .uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Remove from database by setting installed = false // Remove from database by setting installed = false
let key = get_skill_key(&app_type, &directory);
app_state app_state
.db .db
.update_skill_state( .update_skill_state(
&key, &directory,
&SkillState { &SkillState {
installed: false, installed: false,
installed_at: Utc::now(), installed_at: Utc::now(),
+110
View File
@@ -357,6 +357,116 @@ impl Database {
Ok(()) 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<IndexMap<String, Provider>, 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<String> = row.get(3)?;
let category: Option<String> = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let sort_index: Option<usize> = row.get(6)?;
let notes: Option<String> = row.get(7)?;
let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = 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<i64> = 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<Vec<(String, String, String)>, AppError> { pub fn get_all_proxy_targets(&self) -> Result<Vec<(String, String, String)>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
+77
View File
@@ -165,6 +165,25 @@ impl Database {
Ok(()) 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 (可选) ==================== // ==================== Proxy Usage (可选) ====================
/// 记录代理使用统计 /// 记录代理使用统计
@@ -241,4 +260,62 @@ impl Database {
Ok(records) Ok(records)
} }
// ==================== Circuit Breaker Config ====================
/// 获取熔断器配置
pub async fn get_circuit_breaker_config(
&self,
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
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(())
}
} }
+6 -20
View File
@@ -13,22 +13,18 @@ impl Database {
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> { pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = 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()))?; .map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt let skill_iter = stmt
.query_map([], |row| { .query_map([], |row| {
let directory: String = row.get(0)?; let key: String = row.get(0)?;
let app_type: String = row.get(1)?; let installed: bool = row.get(1)?;
let installed: bool = row.get(2)?; let installed_at_ts: i64 = row.get(2)?;
let installed_at_ts: i64 = row.get(3)?;
let installed_at = let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
// 构建复合 key"app_type:directory"
let key = format!("{app_type}:{directory}");
Ok(( Ok((
key, key,
SkillState { SkillState {
@@ -48,21 +44,11 @@ impl Database {
} }
/// 更新 Skill 状态 /// 更新 Skill 状态
/// key 格式为 "app_type:directory"
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { 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); let conn = lock_conn!(self.conn);
conn.execute( conn.execute(
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![directory, app_type, state.installed, state.installed_at.timestamp()], params![key, state.installed, state.installed_at.timestamp()],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
+26 -84
View File
@@ -96,11 +96,9 @@ impl Database {
// 5. Skills 表 // 5. Skills 表
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS skills ( "CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL, key TEXT PRIMARY KEY,
app_type TEXT NOT NULL,
installed BOOLEAN NOT NULL DEFAULT 0, installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0
PRIMARY KEY (directory, app_type)
)", )",
[], [],
) )
@@ -338,6 +336,28 @@ impl Database {
) )
.map_err(|e| AppError::Database(e.to_string()))?; .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(()) Ok(())
} }
@@ -371,9 +391,7 @@ impl Database {
Self::set_user_version(conn, 1)?; Self::set_user_version(conn, 1)?;
} }
1 => { 1 => {
log::info!( log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
"迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
);
Self::migrate_v1_to_v2(conn)?; Self::migrate_v1_to_v2(conn)?;
Self::set_user_version(conn, 2)?; Self::set_user_version(conn, 2)?;
} }
@@ -462,7 +480,7 @@ impl Database {
Ok(()) Ok(())
} }
/// v1 -> v2 迁移:添加使用统计表和完整字段,重构 skills 表 /// v1 -> v2 迁移:添加使用统计表和完整字段
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> { fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
// providers 表字段 // providers 表字段
Self::add_column_if_missing( Self::add_column_if_missing(
@@ -558,82 +576,6 @@ impl Database {
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?; .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
Self::seed_model_pricing(conn)?; 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::<Result<Vec<_>, _>>()
.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(()) Ok(())
} }
+9 -3
View File
@@ -635,11 +635,8 @@ pub fn run() {
commands::restore_env_backup, commands::restore_env_backup,
// Skill management // Skill management
commands::get_skills, commands::get_skills,
commands::get_skills_for_app,
commands::install_skill, commands::install_skill,
commands::install_skill_for_app,
commands::uninstall_skill, commands::uninstall_skill,
commands::uninstall_skill_for_app,
commands::get_skill_repos, commands::get_skill_repos,
commands::add_skill_repo, commands::add_skill_repo,
commands::remove_skill_repo, commands::remove_skill_repo,
@@ -653,6 +650,14 @@ pub fn run() {
commands::get_proxy_config, commands::get_proxy_config,
commands::update_proxy_config, commands::update_proxy_config,
commands::is_proxy_running, 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 // Usage statistics
commands::get_usage_summary, commands::get_usage_summary,
commands::get_usage_trends, commands::get_usage_trends,
@@ -671,6 +676,7 @@ pub fn run() {
commands::save_model_test_config, commands::save_model_test_config,
commands::get_model_test_logs, commands::get_model_test_logs,
commands::cleanup_model_test_logs, commands::cleanup_model_test_logs,
commands::get_tool_versions,
]); ]);
let app = builder let app = builder
+334
View File
@@ -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<RwLock<CircuitState>>,
/// 连续失败计数
consecutive_failures: Arc<AtomicU32>,
/// 连续成功计数(半开状态)
consecutive_successes: Arc<AtomicU32>,
/// 总请求计数
total_requests: Arc<AtomicU32>,
/// 失败请求计数
failed_requests: Arc<AtomicU32>,
/// 上次打开时间
last_opened_at: Arc<RwLock<Option<Instant>>>,
/// 配置
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);
}
}
+99 -33
View File
@@ -4,8 +4,8 @@
use super::{ use super::{
error::*, error::*,
provider_router::ProviderRouter as NewProviderRouter,
providers::{get_adapter, ProviderAdapter}, providers::{get_adapter, ProviderAdapter},
router::ProviderRouter,
types::ProxyStatus, types::ProxyStatus,
ProxyError, ProxyError,
}; };
@@ -18,9 +18,11 @@ use tokio::sync::RwLock;
pub struct RequestForwarder { pub struct RequestForwarder {
client: Client, client: Client,
router: ProviderRouter, router: Arc<NewProviderRouter>,
#[allow(dead_code)]
max_retries: u8, max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
} }
impl RequestForwarder { impl RequestForwarder {
@@ -29,6 +31,7 @@ impl RequestForwarder {
timeout_secs: u64, timeout_secs: u64,
max_retries: u8, max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
) -> Self { ) -> Self {
let mut client_builder = Client::builder(); let mut client_builder = Client::builder();
if timeout_secs > 0 { if timeout_secs > 0 {
@@ -41,13 +44,14 @@ impl RequestForwarder {
Self { Self {
client, client,
router: ProviderRouter::new(db), router: Arc::new(NewProviderRouter::new(db)),
max_retries, max_retries,
status, status,
current_providers,
} }
} }
/// 转发请求(带重试和故障转移) /// 转发请求(带故障转移)
pub async fn forward_with_retry( pub async fn forward_with_retry(
&self, &self,
app_type: &AppType, app_type: &AppType,
@@ -55,21 +59,39 @@ impl RequestForwarder {
body: Value, body: Value,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
) -> Result<Response, ProxyError> { ) -> Result<Response, ProxyError> {
let mut failed_ids = Vec::new();
let mut failover_happened = false;
// 获取适配器 // 获取适配器
let adapter = get_adapter(app_type); let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str();
for attempt in 0..self.max_retries { // 使用新的 ProviderRouter 选择所有可用供应商
// 选择Provider let providers = self
let provider = self.router.select_provider(app_type, &failed_ids).await?; .router
.select_providers(app_type_str)
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
log::debug!( if providers.is_empty() {
"尝试 {} - 使用Provider: {} ({})", 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, attempt + 1,
providers.len(),
provider.name, provider.name,
provider.id provider.sort_index.unwrap_or(999999)
); );
// 更新状态中的当前Provider信息 // 更新状态中的当前Provider信息
@@ -88,16 +110,29 @@ impl RequestForwarder {
// 转发请求 // 转发请求
match self match self
.forward(&provider, endpoint, &body, &headers, adapter.as_ref()) .forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await .await
{ {
Ok(response) => { Ok(response) => {
let _latency = start.elapsed().as_millis() as u64; let latency = start.elapsed().as_millis() as u64;
// 成功:更新健康状态 // 成功:记录成功并更新熔断器
self.router if let Err(e) = self
.update_health(&provider, app_type, true, None) .router
.await; .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; status.last_error = None;
if failover_happened { if failover_happened {
status.failover_count += 1; status.failover_count += 1;
log::info!(
"[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)",
app_type_str,
provider.name,
latency
);
} }
// 重新计算成功率 // 重新计算成功率
if status.total_requests > 0 { if status.total_requests > 0 {
@@ -115,23 +156,33 @@ impl RequestForwarder {
} }
} }
log::info!(
"[{}] 请求成功 - Provider: {} - {}ms",
app_type_str,
provider.name,
latency
);
return Ok(response); return Ok(response);
} }
Err(e) => { Err(e) => {
let latency = start.elapsed().as_millis() as u64; 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); let category = self.categorize_proxy_error(&e);
match category { match category {
ErrorCategory::Retryable => { 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; let mut status = self.status.write().await;
status.last_error = status.last_error =
@@ -139,15 +190,19 @@ impl RequestForwarder {
} }
log::warn!( log::warn!(
"请求失败(可重试): Provider {} - {} - {}ms", "[{}] Provider {} 失败(可重试): {} - {}ms",
app_type_str,
provider.name, provider.name,
e, e,
latency latency
); );
last_error = Some(e);
// 继续尝试下一个供应商
continue; continue;
} }
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => { ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
// 不可重试:更新失败统计并返回 // 不可重试:直接返回错误
{ {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.failed_requests += 1; status.failed_requests += 1;
@@ -158,7 +213,12 @@ impl RequestForwarder {
* 100.0; * 100.0;
} }
} }
log::error!("请求失败(不可重试): {e}"); log::error!(
"[{}] Provider {} 失败(不可重试): {}",
app_type_str,
provider.name,
e
);
return Err(e); return Err(e);
} }
} }
@@ -166,18 +226,24 @@ impl RequestForwarder {
} }
} }
// 所有重试都失败 // 所有供应商都失败
{ {
let mut status = self.status.write().await; let mut status = self.status.write().await;
status.failed_requests += 1; status.failed_requests += 1;
status.last_error = Some("已达到最大重试次数".to_string()); status.last_error = Some("所有供应商都失败".to_string());
if status.total_requests > 0 { if status.total_requests > 0 {
status.success_rate = status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0; (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))
} }
/// 转发单个请求(使用适配器) /// 转发单个请求(使用适配器)
+4
View File
@@ -322,6 +322,7 @@ pub async fn handle_messages(
config.request_timeout, config.request_timeout,
config.max_retries, config.max_retries,
state.status.clone(), state.status.clone(),
state.current_providers.clone(),
); );
let response = forwarder let response = forwarder
@@ -641,6 +642,7 @@ pub async fn handle_gemini(
config.request_timeout, config.request_timeout,
config.max_retries, config.max_retries,
state.status.clone(), state.status.clone(),
state.current_providers.clone(),
); );
// 提取完整的路径和查询参数 // 提取完整的路径和查询参数
@@ -806,6 +808,7 @@ pub async fn handle_responses(
config.request_timeout, config.request_timeout,
config.max_retries, config.max_retries,
state.status.clone(), state.status.clone(),
state.current_providers.clone(),
); );
let response = forwarder let response = forwarder
@@ -985,6 +988,7 @@ pub async fn handle_chat_completions(
config.request_timeout, config.request_timeout,
config.max_retries, config.max_retries,
state.status.clone(), state.status.clone(),
state.current_providers.clone(),
); );
let response = forwarder let response = forwarder
+8
View File
@@ -2,10 +2,12 @@
//! //!
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 //! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
pub mod circuit_breaker;
pub mod error; pub mod error;
mod forwarder; mod forwarder;
mod handlers; mod handlers;
mod health; mod health;
pub mod provider_router;
pub mod providers; pub mod providers;
pub mod response_handler; pub mod response_handler;
mod router; mod router;
@@ -16,8 +18,14 @@ pub mod usage;
// 公开导出给外部使用(commands, services等模块需要) // 公开导出给外部使用(commands, services等模块需要)
#[allow(unused_imports)] #[allow(unused_imports)]
pub use circuit_breaker::{
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
};
#[allow(unused_imports)]
pub use error::ProxyError; pub use error::ProxyError;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use provider_router::ProviderRouter;
#[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler}; pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use session::{ClientFormat, ProxySession}; pub use session::{ClientFormat, ProxySession};
+216
View File
@@ -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<Database>,
/// 熔断器管理器 - key 格式: "app_type:provider_id"
circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,
}
impl ProviderRouter {
/// 创建新的供应商路由器
pub fn new(db: Arc<Database>) -> Self {
Self {
db,
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
}
}
/// 选择可用的供应商(支持故障转移)
/// 返回按优先级排序的可用供应商列表
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, 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<String>,
) -> 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<crate::proxy::circuit_breaker::CircuitBreakerStats> {
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<CircuitBreaker> {
// 先尝试读锁获取
{
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);
}
}
+1
View File
@@ -57,6 +57,7 @@ impl ProviderRouter {
} }
/// 更新Provider健康状态(保留接口但不影响选择) /// 更新Provider健康状态(保留接口但不影响选择)
#[allow(dead_code)]
pub async fn update_health( pub async fn update_health(
&self, &self,
_provider: &Provider, _provider: &Provider,
+13 -11
View File
@@ -20,6 +20,8 @@ pub struct ProxyState {
pub config: Arc<RwLock<ProxyConfig>>, pub config: Arc<RwLock<ProxyConfig>>,
pub status: Arc<RwLock<ProxyStatus>>, pub status: Arc<RwLock<ProxyStatus>>,
pub start_time: Arc<RwLock<Option<std::time::Instant>>>, pub start_time: Arc<RwLock<Option<std::time::Instant>>>,
/// 每个应用类型当前使用的 provider (app_type -> (provider_id, provider_name))
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
} }
/// 代理HTTP服务器 /// 代理HTTP服务器
@@ -36,6 +38,7 @@ impl ProxyServer {
config: Arc::new(RwLock::new(config.clone())), config: Arc::new(RwLock::new(config.clone())),
status: Arc::new(RwLock::new(ProxyStatus::default())), status: Arc::new(RwLock::new(ProxyStatus::default())),
start_time: Arc::new(RwLock::new(None)), start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
}; };
Self { Self {
@@ -121,17 +124,16 @@ impl ProxyServer {
status.uptime_seconds = start.elapsed().as_secs(); status.uptime_seconds = start.elapsed().as_secs();
} }
// 获取所有活跃的代理目标 // 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider
if let Ok(targets) = self.state.db.get_all_proxy_targets() { let current_providers = self.state.current_providers.read().await;
status.active_targets = targets status.active_targets = current_providers
.into_iter() .iter()
.map(|(app_type, name, id)| ActiveTarget { .map(|(app_type, (provider_id, provider_name))| ActiveTarget {
app_type, app_type: app_type.clone(),
provider_name: name, provider_id: provider_id.clone(),
provider_id: id, provider_name: provider_name.clone(),
}) })
.collect(); .collect();
}
status status
} }
+3 -31
View File
@@ -7,7 +7,6 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::time::timeout; use tokio::time::timeout;
use crate::app_config::AppType;
use crate::error::format_skill_error; use crate::error::format_skill_error;
/// 技能对象 /// 技能对象
@@ -107,16 +106,11 @@ pub struct SkillMetadata {
pub struct SkillService { pub struct SkillService {
http_client: Client, http_client: Client,
install_dir: PathBuf, install_dir: PathBuf,
app_type: AppType,
} }
impl SkillService { impl SkillService {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Self::new_for_app(AppType::Claude) let install_dir = Self::get_install_dir()?;
}
pub fn new_for_app(app_type: AppType) -> Result<Self> {
let install_dir = Self::get_install_dir_for_app(&app_type)?;
// 确保目录存在 // 确保目录存在
fs::create_dir_all(&install_dir)?; fs::create_dir_all(&install_dir)?;
@@ -128,38 +122,16 @@ impl SkillService {
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build()?, .build()?,
install_dir, install_dir,
app_type,
}) })
} }
fn get_install_dir_for_app(app_type: &AppType) -> Result<PathBuf> { fn get_install_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context(format_skill_error( let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED", "GET_HOME_DIR_FAILED",
&[], &[],
Some("checkPermission"), Some("checkPermission"),
))?; ))?;
Ok(home.join(".claude").join("skills"))
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
} }
} }
+11 -5
View File
@@ -23,6 +23,7 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions"; import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { AppSwitcher } from "@/components/AppSwitcher"; import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList"; import { ProviderList } from "@/components/providers/ProviderList";
@@ -61,7 +62,13 @@ function App() {
const addActionButtonClass = 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"; "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 providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? ""; const currentProviderId = data?.currentProviderId ?? "";
// Skills 功能仅支持 Claude 和 Codex // Skills 功能仅支持 Claude 和 Codex
@@ -74,7 +81,6 @@ function App() {
switchProvider, switchProvider,
deleteProvider, deleteProvider,
saveUsageScript, saveUsageScript,
setProxyTarget,
} = useProviderActions(activeApp); } = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件 // 监听来自托盘菜单的切换事件
@@ -314,8 +320,8 @@ function App() {
currentProviderId={currentProviderId} currentProviderId={currentProviderId}
appId={activeApp} appId={activeApp}
isLoading={isLoading} isLoading={isLoading}
isProxyRunning={isProxyRunning}
onSwitch={switchProvider} onSwitch={switchProvider}
onSetProxyTarget={setProxyTarget}
onEdit={setEditingProvider} onEdit={setEditingProvider}
onDelete={setConfirmDelete} onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider} onDuplicate={handleDuplicateProvider}
@@ -369,7 +375,7 @@ function App() {
)} )}
<header <header
className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300" className="fixed top-0 z-50 w-full py-3 bg-background/80 backdrop-blur-md transition-all duration-300"
data-tauri-drag-region data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any} style={{ WebkitAppRegion: "drag" } as any}
> >
@@ -478,7 +484,7 @@ function App() {
<> <>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} /> <AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="glass p-1 rounded-xl flex items-center gap-1"> <div className="bg-muted p-1 rounded-xl flex items-center gap-1">
{hasSkillsSupport && ( {hasSkillsSupport && (
<Button <Button
variant="ghost" variant="ghost"
+12 -12
View File
@@ -24,14 +24,14 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
}; };
return ( return (
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1"> <div className="inline-flex bg-muted rounded-lg p-1 gap-1">
<button <button
type="button" type="button"
onClick={() => handleSwitch("claude")} onClick={() => handleSwitch("claude")}
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude" activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100" ? "bg-background text-foreground shadow-sm"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`} }`}
> >
<ProviderIcon <ProviderIcon
@@ -41,7 +41,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={ className={
activeApp === "claude" activeApp === "claude"
? "text-foreground" ? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors" : "text-muted-foreground group-hover:text-foreground transition-colors"
} }
/> />
<span>{appDisplayName.claude}</span> <span>{appDisplayName.claude}</span>
@@ -50,10 +50,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("codex")} onClick={() => handleSwitch("codex")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex" activeApp === "codex"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100" ? "bg-background text-foreground shadow-sm"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`} }`}
> >
<ProviderIcon <ProviderIcon
@@ -63,7 +63,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={ className={
activeApp === "codex" activeApp === "codex"
? "text-foreground" ? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors" : "text-muted-foreground group-hover:text-foreground transition-colors"
} }
/> />
<span>{appDisplayName.codex}</span> <span>{appDisplayName.codex}</span>
@@ -72,10 +72,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button <button
type="button" type="button"
onClick={() => handleSwitch("gemini")} onClick={() => handleSwitch("gemini")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${ className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "gemini" activeApp === "gemini"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100" ? "bg-background text-foreground shadow-sm"
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60" : "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`} }`}
> >
<ProviderIcon <ProviderIcon
@@ -85,7 +85,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={ className={
activeApp === "gemini" activeApp === "gemini"
? "text-foreground" ? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors" : "text-muted-foreground group-hover:text-foreground transition-colors"
} }
/> />
<span>{appDisplayName.gemini}</span> <span>{appDisplayName.gemini}</span>
+42 -16
View File
@@ -33,13 +33,23 @@ export function EditProviderDialog({
unknown unknown
> | null>(null); > | null>(null);
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
const [hasLoadedLive, setHasLoadedLive] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const load = async () => { const load = async () => {
if (!open || !provider) { if (!open || !provider) {
setLiveSettings(null); setLiveSettings(null);
setHasLoadedLive(false);
return; return;
} }
// 关键修复:只在首次打开时加载一次
if (hasLoadedLive) {
return;
}
try { try {
const currentId = await providersApi.getCurrent(appId); const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) { if (currentId && provider.id === currentId) {
@@ -49,13 +59,20 @@ export function EditProviderDialog({
)) as Record<string, unknown>; )) as Record<string, unknown>;
if (!cancelled && live && typeof live === "object") { if (!cancelled && live && typeof live === "object") {
setLiveSettings(live); setLiveSettings(live);
setHasLoadedLive(true);
} }
} catch { } catch {
// 读取实时配置失败则回退到 SSOT(不打断编辑流程) // 读取实时配置失败则回退到 SSOT(不打断编辑流程)
if (!cancelled) setLiveSettings(null); if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
} }
} else { } else {
if (!cancelled) setLiveSettings(null); if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
} }
} finally { } finally {
// no-op // no-op
@@ -65,14 +82,33 @@ export function EditProviderDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, provider, appId]); }, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => { const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record< return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
string, string,
unknown unknown
>; >;
}, [liveSettings, provider]); }, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig,不依赖整个 provider
// 固定 initialData,防止 provider 对象更新时重置表单
const initialData = useMemo(() => {
if (!provider) return null;
return {
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
icon: provider.icon,
iconColor: provider.iconColor,
};
}, [
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
initialSettingsConfig,
// 注意:不依赖 provider 的其他字段,防止表单重置
]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (values: ProviderFormValues) => { async (values: ProviderFormValues) => {
@@ -104,7 +140,7 @@ export function EditProviderDialog({
[onSubmit, onOpenChange, provider], [onSubmit, onOpenChange, provider],
); );
if (!provider) { if (!provider || !initialData) {
return null; return null;
} }
@@ -130,17 +166,7 @@ export function EditProviderDialog({
submitLabel={t("common.save")} submitLabel={t("common.save")}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
initialData={{ initialData={initialData}
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
// 若读取到实时配置则优先使用
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
icon: provider.icon,
iconColor: provider.iconColor,
}}
showButtons={false} showButtons={false}
/> />
</FullScreenPanel> </FullScreenPanel>
@@ -7,6 +7,7 @@ import {
Play, Play,
TestTube2, TestTube2,
Trash2, Trash2,
RotateCcw,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -21,6 +22,9 @@ interface ProviderActionsProps {
onTest?: () => void; onTest?: () => void;
onConfigureUsage: () => void; onConfigureUsage: () => void;
onDelete: () => void; onDelete: () => void;
onResetCircuitBreaker?: () => void;
isProxyTarget?: boolean;
consecutiveFailures?: number;
} }
export function ProviderActions({ export function ProviderActions({
@@ -32,6 +36,9 @@ export function ProviderActions({
onTest, onTest,
onConfigureUsage, onConfigureUsage,
onDelete, onDelete,
onResetCircuitBreaker,
isProxyTarget,
consecutiveFailures = 0,
}: ProviderActionsProps) { }: ProviderActionsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1"; const iconButtonClass = "h-8 w-8 p-1";
@@ -110,6 +117,32 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" /> <BarChart3 className="h-4 w-4" />
</Button> </Button>
{/* 重置熔断器按钮 - 代理目标启用时显示 */}
{onResetCircuitBreaker && isProxyTarget && (
<Button
size="icon"
variant="ghost"
onClick={onResetCircuitBreaker}
disabled={consecutiveFailures === 0}
title={
consecutiveFailures > 0
? t("provider.resetCircuitBreaker", {
defaultValue: "重置熔断器",
})
: t("provider.noFailures", {
defaultValue: "当前无失败记录",
})
}
className={cn(
iconButtonClass,
consecutiveFailures > 0 &&
"hover:text-orange-500 dark:hover:text-orange-400",
)}
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
+145 -12
View File
@@ -13,6 +13,13 @@ import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter"; import UsageFooter from "@/components/UsageFooter";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import {
useProviderHealth,
useResetCircuitBreaker,
useSetProxyTarget,
} from "@/lib/query/failover";
import { toast } from "sonner";
interface DragHandleProps { interface DragHandleProps {
attributes: DraggableAttributes; attributes: DraggableAttributes;
@@ -32,8 +39,9 @@ interface ProviderCardProps {
onDuplicate: (provider: Provider) => void; onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void; onTest?: (provider: Provider) => void;
isTesting?: boolean; isTesting?: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean; isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
dragHandleProps?: DragHandleProps; dragHandleProps?: DragHandleProps;
} }
@@ -84,12 +92,109 @@ export function ProviderCard({
onDuplicate, onDuplicate,
onTest, onTest,
isTesting, isTesting,
onSetProxyTarget,
isProxyRunning, isProxyRunning,
proxyPriority,
allProviders,
dragHandleProps, dragHandleProps,
}: ProviderCardProps) { }: ProviderCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// 获取供应商健康状态
const { data: health } = useProviderHealth(provider.id, appId);
// 设置代理目标
const setProxyTargetMutation = useSetProxyTarget();
// 重置熔断器
const resetCircuitBreaker = useResetCircuitBreaker();
const handleSetProxyTarget = async (enabled: boolean) => {
try {
await setProxyTargetMutation.mutateAsync({
providerId: provider.id,
appType: appId,
enabled,
});
// 计算实际优先级(开启时)
let actualPriority: number | undefined;
if (enabled && allProviders) {
// 模拟开启后的状态:获取所有将要启用代理的 providers
const futureProxyTargets = allProviders.filter((p) => {
// 包括:已经是代理目标的 或 当前要开启的这个
if (p.id === provider.id) return true;
return p.isProxyTarget;
});
// 按 sortIndex 排序
const sortedTargets = futureProxyTargets.sort((a, b) => {
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
return indexA - indexB;
});
// 找到当前 provider 的位置
const position = sortedTargets.findIndex((p) => p.id === provider.id);
actualPriority = position >= 0 ? position + 1 : undefined;
}
const message = enabled
? actualPriority
? t("provider.proxyTargetEnabled", {
defaultValue: `已启用代理目标(优先级:P${actualPriority}`,
})
: t("provider.proxyTargetEnabled", {
defaultValue: "已启用代理目标",
})
: t("provider.proxyTargetDisabled", {
defaultValue: "已禁用代理目标",
});
const description = enabled
? t("provider.proxyTargetEnabledDesc", {
defaultValue: "下次请求将按优先级自动选择此供应商",
})
: t("provider.proxyTargetDisabledDesc", {
defaultValue: "后续请求将使用其他可用供应商",
});
toast.success(message, {
description,
duration: 4000,
});
} catch (error) {
toast.error(
t("provider.setProxyTargetFailed", {
defaultValue: "操作失败",
}) +
": " +
String(error),
);
}
};
const handleResetCircuitBreaker = async () => {
try {
await resetCircuitBreaker.mutateAsync({
providerId: provider.id,
appType: appId,
});
toast.success(
t("provider.circuitBreakerReset", {
defaultValue: "熔断器已重置",
}),
);
} catch (error) {
toast.error(
t("provider.circuitBreakerResetFailed", {
defaultValue: "重置失败",
}) +
": " +
String(error),
);
}
};
const fallbackUrlText = t("provider.notConfigured", { const fallbackUrlText = t("provider.notConfigured", {
defaultValue: "未配置接口地址", defaultValue: "未配置接口地址",
}); });
@@ -124,9 +229,9 @@ export function ProviderCard({
return ( return (
<div <div
className={cn( className={cn(
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300", "relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50", "bg-card text-card-foreground group hover:border-border-active",
isCurrent ? "glass-card-active" : "hover:scale-[1.01]", isCurrent ? "border-primary/50 shadow-sm" : "hover:shadow-sm",
dragHandleProps?.isDragging && dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10", "cursor-grabbing border-primary shadow-lg scale-105 z-10",
)} )}
@@ -149,7 +254,7 @@ export function ProviderCard({
</button> </button>
{/* 供应商图标 */} {/* 供应商图标 */}
<div className="h-8 w-8 rounded-lg bg-white/5 flex items-center justify-center border border-gray-200 dark:border-white/10 group-hover:scale-105 transition-transform duration-300"> <div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon <ProviderIcon
icon={provider.icon} icon={provider.icon}
name={provider.name} name={provider.name}
@@ -163,6 +268,29 @@ export function ProviderCard({
<h3 className="text-base font-semibold leading-none"> <h3 className="text-base font-semibold leading-none">
{provider.name} {provider.name}
</h3> </h3>
{/* 健康状态徽章和优先级 */}
{isProxyRunning && (
<div className="flex items-center gap-1.5">
{/* 健康徽章:代理目标启用时始终显示,没有健康数据时默认为正常(0失败) */}
{(provider.isProxyTarget || health) && (
<ProviderHealthBadge
consecutiveFailures={health?.consecutive_failures ?? 0}
isProxyTarget={provider.isProxyTarget ?? false}
/>
)}
{/* 优先级:仅在代理目标启用时显示 */}
{provider.isProxyTarget && proxyPriority && (
<span
className="text-xs text-muted-foreground"
title={`代理队列优先级:第${proxyPriority}`}
>
P{proxyPriority}
</span>
)}
</div>
)}
{provider.category === "third_party" && {provider.category === "third_party" &&
provider.meta?.isPartner && ( provider.meta?.isPartner && (
<span <span
@@ -185,17 +313,15 @@ export function ProviderCard({
id={`proxy-target-switch-${provider.id}`} id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false} checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (checked && !provider.isProxyTarget) { handleSetProxyTarget(checked);
onSetProxyTarget(provider);
}
}} }}
disabled={provider.isProxyTarget} disabled={setProxyTargetMutation.isPending}
className="scale-75 data-[state=checked]:bg-purple-500" className="scale-75 data-[state=checked]:bg-green-500"
/> />
{provider.isProxyTarget && ( {provider.isProxyTarget && (
<Label <Label
htmlFor={`proxy-target-switch-${provider.id}`} htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs font-medium text-purple-500 dark:text-purple-400 cursor-pointer" className="text-xs font-medium text-green-600 dark:text-green-400 cursor-pointer"
> >
{t("provider.proxyTarget", { defaultValue: "代理目标" })} {t("provider.proxyTarget", { defaultValue: "代理目标" })}
</Label> </Label>
@@ -255,6 +381,13 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined} onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)} onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)} onDelete={() => onDelete(provider)}
onResetCircuitBreaker={
isProxyRunning && provider.isProxyTarget
? handleResetCircuitBreaker
: undefined
}
isProxyTarget={provider.isProxyTarget}
consecutiveFailures={health?.consecutive_failures ?? 0}
/> />
</div> </div>
</div> </div>
@@ -10,7 +10,7 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 p-10 text-center"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-10 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Users className="h-7 w-7 text-muted-foreground" /> <Users className="h-7 w-7 text-muted-foreground" />
</div> </div>
@@ -0,0 +1,70 @@
import { cn } from "@/lib/utils";
import { ProviderHealthStatus } from "@/types/proxy";
interface ProviderHealthBadgeProps {
consecutiveFailures: number;
isProxyTarget?: boolean;
className?: string;
}
/**
*
*
*/
export function ProviderHealthBadge({
consecutiveFailures,
isProxyTarget,
className,
}: ProviderHealthBadgeProps) {
// 如果代理目标已关闭但有失败记录,仍然显示(自动熔断场景)
// 如果代理目标启用,始终显示
// 如果代理目标关闭且无失败记录,隐藏
if (!isProxyTarget && consecutiveFailures === 0) return null;
// 根据失败次数计算状态
const getStatus = () => {
if (consecutiveFailures === 0) {
return {
label: "正常",
status: ProviderHealthStatus.Healthy,
color: "bg-green-500",
// 使用更深/柔和的背景色,去除可能的白色内容感
bgColor: "bg-green-500/10",
textColor: "text-green-600 dark:text-green-400",
};
} else if (consecutiveFailures < 5) {
return {
label: "降级",
status: ProviderHealthStatus.Degraded,
color: "bg-yellow-500",
bgColor: "bg-yellow-500/10",
textColor: "text-yellow-600 dark:text-yellow-400",
};
} else {
return {
label: "熔断",
status: ProviderHealthStatus.Failed,
color: "bg-red-500",
bgColor: "bg-red-500/10",
textColor: "text-red-600 dark:text-red-400",
};
}
};
const statusConfig = getStatus();
return (
<div
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium",
statusConfig.bgColor,
statusConfig.textColor,
className,
)}
title={`连续失败 ${consecutiveFailures}`}
>
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
<span>{statusConfig.label}</span>
</div>
);
}
+32 -10
View File
@@ -5,11 +5,11 @@ import {
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { useMemo } from "react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { Provider } from "@/types"; import type { Provider } from "@/types";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort"; import { useDragSort } from "@/hooks/useDragSort";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useModelTest } from "@/hooks/useModelTest"; import { useModelTest } from "@/hooks/useModelTest";
import { ProviderCard } from "@/components/providers/ProviderCard"; import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState"; import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
@@ -26,7 +26,7 @@ interface ProviderListProps {
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onCreate?: () => void; onCreate?: () => void;
isLoading?: boolean; isLoading?: boolean;
onSetProxyTarget: (provider: Provider) => void; isProxyRunning?: boolean; // 代理服务运行状态
} }
export function ProviderList({ export function ProviderList({
@@ -41,19 +41,37 @@ export function ProviderList({
onOpenWebsite, onOpenWebsite,
onCreate, onCreate,
isLoading = false, isLoading = false,
onSetProxyTarget, isProxyRunning = false, // 默认值为 false
}: ProviderListProps) { }: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort( const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers, providers,
appId, appId,
); );
// 获取代理服务运行状态
const { isRunning: isProxyRunning } = useProxyStatus();
// 模型测试 // 模型测试
const { testProvider, isTesting } = useModelTest(appId); const { testProvider, isTesting } = useModelTest(appId);
// 计算代理目标的实际优先级映射 (P1, P2, P3...)
const proxyPriorityMap = useMemo(() => {
// 获取所有启用代理目标的供应商
const proxyTargets = sortedProviders.filter((p) => p.isProxyTarget);
// 按 sortIndex 排序
const sortedTargets = proxyTargets.sort((a, b) => {
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
return indexA - indexB;
});
// 创建优先级映射
const map = new Map<string, number>();
sortedTargets.forEach((provider, index) => {
map.set(provider.id, index + 1); // P1, P2, P3...
});
return map;
}, [sortedProviders]);
const handleTest = (provider: Provider) => { const handleTest = (provider: Provider) => {
testProvider(provider.id, provider.name); testProvider(provider.id, provider.name);
}; };
@@ -103,8 +121,9 @@ export function ProviderList({
onOpenWebsite={onOpenWebsite} onOpenWebsite={onOpenWebsite}
onTest={handleTest} onTest={handleTest}
isTesting={isTesting(provider.id)} isTesting={isTesting(provider.id)}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
proxyPriority={proxyPriorityMap.get(provider.id)}
allProviders={sortedProviders}
/> />
))} ))}
</div> </div>
@@ -125,8 +144,9 @@ interface SortableProviderCardProps {
onOpenWebsite: (url: string) => void; onOpenWebsite: (url: string) => void;
onTest: (provider: Provider) => void; onTest: (provider: Provider) => void;
isTesting: boolean; isTesting: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean; isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表
} }
function SortableProviderCard({ function SortableProviderCard({
@@ -141,8 +161,9 @@ function SortableProviderCard({
onOpenWebsite, onOpenWebsite,
onTest, onTest,
isTesting, isTesting,
onSetProxyTarget,
isProxyRunning, isProxyRunning,
proxyPriority,
allProviders,
}: SortableProviderCardProps) { }: SortableProviderCardProps) {
const { const {
setNodeRef, setNodeRef,
@@ -174,8 +195,9 @@ function SortableProviderCard({
onOpenWebsite={onOpenWebsite} onOpenWebsite={onOpenWebsite}
onTest={onTest} onTest={onTest}
isTesting={isTesting} isTesting={isTesting}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning} isProxyRunning={isProxyRunning}
proxyPriority={proxyPriority}
allProviders={allProviders}
dragHandleProps={{ dragHandleProps={{
attributes, attributes,
listeners, listeners,
@@ -0,0 +1,336 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
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 { Save, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
export interface AutoFailoverConfigPanelProps {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
}
export function AutoFailoverConfigPanel({
enabled,
onEnabledChange,
}: AutoFailoverConfigPanelProps) {
const { t } = useTranslation();
const { data: config, isLoading, error } = 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({
failureThreshold: formData.failureThreshold,
successThreshold: formData.successThreshold,
timeoutSeconds: formData.timeoutSeconds,
errorRateThreshold: formData.errorRateThreshold,
minRequests: formData.minRequests,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
);
} catch (e) {
toast.error(
t("proxy.autoFailover.configSaveFailed", "保存失败") + ": " + String(e),
);
}
};
const handleReset = () => {
if (config) {
setFormData({
...config,
});
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="border-0 rounded-none shadow-none bg-transparent">
{/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
Since we need it in the accordion header, and this component is inside the content, we can use a portal or
absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
Better yet, let's just render the content directly without the wrapping Card header/collapse logic
since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
*/}
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
<Alert className="border-blue-500/40 bg-blue-500/10">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
{t(
"proxy.autoFailover.info",
"当启用多个代理目标时,系统会按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会自动打开,跳过该供应商。",
)}
</AlertDescription>
</Alert>
{/* 重试与超时配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.retrySettings", "重试与超时设置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="failureThreshold">
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
</Label>
<Input
id="failureThreshold"
type="number"
min="1"
max="20"
value={formData.failureThreshold}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
}
disabled={!enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.failureThresholdHint",
"连续失败多少次后打开熔断器(建议: 3-10)",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="timeoutSeconds">
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
</Label>
<Input
id="timeoutSeconds"
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={!enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.timeoutHint",
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
)}
</p>
</div>
</div>
</div>
{/* 熔断器高级配置 */}
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
<h4 className="text-sm font-semibold">
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="successThreshold">
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
</Label>
<Input
id="successThreshold"
type="number"
min="1"
max="10"
value={formData.successThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
}
disabled={!enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.successThresholdHint",
"半开状态下成功多少次后关闭熔断器",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="errorRateThreshold">
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
</Label>
<Input
id="errorRateThreshold"
type="number"
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
})
}
disabled={!enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.errorRateHint",
"错误率超过此值时打开熔断器",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="minRequests">
{t("proxy.autoFailover.minRequests", "最小请求数")}
</Label>
<Input
id="minRequests"
type="number"
min="5"
max="100"
value={formData.minRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
})
}
disabled={!enabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"proxy.autoFailover.minRequestsHint",
"计算错误率前的最小请求数",
)}
</p>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-3 pt-2">
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending || !enabled}
>
{t("common.reset", "重置")}
</Button>
<Button
onClick={handleSave}
disabled={updateConfig.isPending || !formData.enabled}
>
{updateConfig.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", "保存中...")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", "保存")}
</>
)}
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("proxy.autoFailover.explanationTitle", "工作原理")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
</strong>
{t(
"proxy.autoFailover.failureThresholdExplain",
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
</strong>
{t(
"proxy.autoFailover.timeoutExplain",
"熔断器打开后,等待此时间后尝试半开状态",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
</strong>
{t(
"proxy.autoFailover.successThresholdExplain",
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
)}
</li>
<li>
{" "}
<strong>
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
</strong>
{t(
"proxy.autoFailover.errorRateExplain",
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
)}
</li>
</ul>
</div>
</div>
</div>
);
}
@@ -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 <div className="text-sm text-muted-foreground">...</div>;
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
<div className="h-px bg-border my-4" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 失败阈值 */}
<div className="space-y-2">
<Label htmlFor="failureThreshold"></Label>
<Input
id="failureThreshold"
type="number"
min="1"
max="20"
value={formData.failureThreshold}
onChange={(e) =>
setFormData({
...formData,
failureThreshold: parseInt(e.target.value) || 5,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 超时时间 */}
<div className="space-y-2">
<Label htmlFor="timeoutSeconds"></Label>
<Input
id="timeoutSeconds"
type="number"
min="10"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({
...formData,
timeoutSeconds: parseInt(e.target.value) || 60,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 成功阈值 */}
<div className="space-y-2">
<Label htmlFor="successThreshold"></Label>
<Input
id="successThreshold"
type="number"
min="1"
max="10"
value={formData.successThreshold}
onChange={(e) =>
setFormData({
...formData,
successThreshold: parseInt(e.target.value) || 2,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 错误率阈值 */}
<div className="space-y-2">
<Label htmlFor="errorRateThreshold"> (%)</Label>
<Input
id="errorRateThreshold"
type="number"
min="0"
max="100"
step="5"
value={Math.round(formData.errorRateThreshold * 100)}
onChange={(e) =>
setFormData({
...formData,
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 最小请求数 */}
<div className="space-y-2">
<Label htmlFor="minRequests"></Label>
<Input
id="minRequests"
type="number"
min="5"
max="100"
value={formData.minRequests}
onChange={(e) =>
setFormData({
...formData,
minRequests: parseInt(e.target.value) || 10,
})
}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
<div className="flex gap-3">
<Button onClick={handleSave} disabled={updateConfig.isPending}>
{updateConfig.isPending ? "保存中..." : "保存配置"}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending}
>
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium"></h4>
<ul className="space-y-1 text-muted-foreground">
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
<li>
<strong></strong>
</li>
</ul>
</div>
</div>
);
}
+163 -73
View File
@@ -1,27 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { Switch } from "@/components/ui/switch"; import { Activity, Clock, TrendingUp, Server, ListOrdered } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { Settings, Activity, Clock, TrendingUp, Server } from "lucide-react";
import { ProxySettingsDialog } from "./ProxySettingsDialog"; import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner"; 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() { export function ProxyPanel() {
const { status, isRunning, start, stop, isPending } = useProxyStatus(); const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const handleToggle = async () => { // 获取所有三个应用类型的代理目标列表
try { const { data: claudeTargets = [] } = useProxyTargets("claude");
if (isRunning) { const { data: codexTargets = [] } = useProxyTargets("codex");
await stop(); const { data: geminiTargets = [] } = useProxyTargets("gemini");
} else {
await start();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
}
};
const formatUptime = (seconds: number): string => { const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
@@ -39,58 +34,12 @@ export function ProxyPanel() {
return ( return (
<> <>
<section className="space-y-6 rounded-xl border border-white/10 glass-card p-6"> <section className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<Server className="h-5 w-5" />
</div>
<div>
<h3 className="text-base font-semibold text-foreground">
</h3>
<p className="text-sm text-muted-foreground">
{isRunning
? `运行中 · ${status?.address}:${status?.port}`
: "已停止"}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning ? "运行中" : "已停止"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSettings(true)}
disabled={isPending}
aria-label="打开代理设置"
>
<Settings className="h-4 w-4" />
</Button>
<Switch
checked={isRunning}
onCheckedChange={handleToggle}
disabled={isPending}
aria-label={isRunning ? "停止代理服务" : "启动代理服务"}
/>
</div>
</div>
{isRunning && status ? ( {isRunning && status ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-lg border border-white/10 bg-muted/40 p-4 space-y-4"> <div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <p className="text-xs text-muted-foreground"></p>
</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60"> <code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port} http://{status.address}:{status.port}
@@ -110,16 +59,14 @@ export function ProxyPanel() {
</div> </div>
</div> </div>
<div className="pt-3 border-t border-white/10 space-y-2"> <div className="pt-3 border-t border-border space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <p className="text-xs text-muted-foreground">使</p>
</p>
{status.active_targets && status.active_targets.length > 0 ? ( {status.active_targets && status.active_targets.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{status.active_targets.map((target) => ( {status.active_targets.map((target) => (
<div <div
key={target.app_type} key={target.app_type}
className="flex items-center justify-between rounded-md border border-white/10 bg-background/60 px-2 py-1.5 text-xs" className="flex items-center justify-between rounded-md border border-border bg-background/60 px-2 py-1.5 text-xs"
> >
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{target.app_type} {target.app_type}
@@ -146,6 +93,50 @@ export function ProxyPanel() {
</p> </p>
)} )}
</div> </div>
{/* 供应商队列 - 按应用类型分组展示 */}
{(claudeTargets.length > 0 ||
codexTargets.length > 0 ||
geminiTargets.length > 0) && (
<div className="pt-3 border-t border-border space-y-3">
<div className="flex items-center gap-2">
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* Claude 队列 */}
{claudeTargets.length > 0 && (
<ProviderQueueGroup
appType="claude"
appLabel="Claude"
targets={claudeTargets}
status={status}
/>
)}
{/* Codex 队列 */}
{codexTargets.length > 0 && (
<ProviderQueueGroup
appType="codex"
appLabel="Codex"
targets={codexTargets}
status={status}
/>
)}
{/* Gemini 队列 */}
{geminiTargets.length > 0 && (
<ProviderQueueGroup
appType="gemini"
appLabel="Gemini"
targets={geminiTargets}
status={status}
/>
)}
</div>
)}
</div> </div>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
@@ -208,15 +199,114 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
return ( return (
<div <div
className={`rounded-lg border border-white/10 bg-white/70 p-4 text-sm text-muted-foreground dark:bg-white/5 ${variantStyles[variant]}`} className={`rounded-lg border border-border bg-card/60 p-4 text-sm text-muted-foreground ${variantStyles[variant]}`}
> >
<div className="flex items-center gap-2 text-muted-foreground mb-2"> <div className="flex items-center gap-2 text-muted-foreground mb-2">
{icon} {icon}
<span className="text-xs font-medium uppercase tracking-wide"> <span className="text-xs">{label}</span>
{label}
</span>
</div> </div>
<p className="text-xl font-semibold text-foreground">{value}</p> <p className="text-xl font-semibold text-foreground">{value}</p>
</div> </div>
); );
} }
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 (
<div className="space-y-2">
{/* 应用类型标题 */}
<div className="flex items-center gap-2 px-2">
<span className="text-xs font-semibold text-foreground/80">
{appLabel}
</span>
<div className="flex-1 h-px bg-border/50" />
</div>
{/* 供应商列表 */}
<div className="space-y-1.5">
{targets.map((target, index) => (
<ProviderQueueItem
key={target.id}
provider={target}
priority={index + 1}
appType={appType}
isCurrent={activeTarget?.provider_id === target.id}
/>
))}
</div>
</div>
);
}
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 (
<div
className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm transition-colors ${
isCurrent
? "border-primary/40 bg-primary/10 text-primary font-medium"
: "border-border bg-background/60"
}`}
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold ${
isCurrent
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{priority}
</span>
<span className={isCurrent ? "" : "text-foreground"}>
{provider.name}
</span>
{isCurrent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
使
</span>
)}
</div>
{/* 健康徽章:队列中的代理目标始终显示,没有健康数据时默认为正常 */}
<ProviderHealthBadge
consecutiveFailures={health?.consecutive_failures ?? 0}
isProxyTarget={true}
/>
</div>
);
}
+115 -29
View File
@@ -1,5 +1,14 @@
import { useCallback, useEffect, useState } from "react"; 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 { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -7,16 +16,27 @@ import { getVersion } from "@tauri-apps/api/app";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
import { useUpdate } from "@/contexts/UpdateContext"; import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater"; import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
interface AboutSectionProps { interface AboutSectionProps {
isPortable: boolean; isPortable: boolean;
} }
interface ToolVersion {
name: string;
version: string | null;
latest_version: string | null;
error: string | null;
}
export function AboutSection({ isPortable }: AboutSectionProps) { export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
const { t } = useTranslation(); const { t } = useTranslation();
const [version, setVersion] = useState<string | null>(null); const [version, setVersion] = useState<string | null>(null);
const [isLoadingVersion, setIsLoadingVersion] = useState(true); const [isLoadingVersion, setIsLoadingVersion] = useState(true);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
const [isLoadingTools, setIsLoadingTools] = useState(true);
const { const {
hasUpdate, hasUpdate,
@@ -31,18 +51,24 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
let active = true; let active = true;
const load = async () => { const load = async () => {
try { try {
const loaded = await getVersion(); const [appVersion, tools] = await Promise.all([
getVersion(),
settingsApi.getToolVersions(),
]);
if (active) { if (active) {
setVersion(loaded); setVersion(appVersion);
setToolVersions(tools);
} }
} catch (error) { } catch (error) {
console.error("[AboutSection] Failed to get version", error); console.error("[AboutSection] Failed to load info", error);
if (active) { if (active) {
setVersion(null); setVersion(null);
} }
} finally { } finally {
if (active) { if (active) {
setIsLoadingVersion(false); setIsLoadingVersion(false);
setIsLoadingTools(false);
} }
} }
}; };
@@ -53,6 +79,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}; };
}, []); }, []);
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
const handleOpenReleaseNotes = useCallback(async () => { const handleOpenReleaseNotes = useCallback(async () => {
try { try {
const targetVersion = updateInfo?.availableVersion ?? version ?? ""; const targetVersion = updateInfo?.availableVersion ?? version ?? "";
@@ -125,7 +153,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const displayVersion = version ?? t("common.unknown"); const displayVersion = version ?? t("common.unknown");
return ( return (
<section className="space-y-4"> <section className="space-y-6">
<header className="space-y-1"> <header className="space-y-1">
<h3 className="text-sm font-medium">{t("common.about")}</h3> <h3 className="text-sm font-medium">{t("common.about")}</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -133,24 +161,28 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</p> </p>
</header> </header>
<div className="space-y-4 rounded-lg border border-border-default p-4"> <div className="rounded-xl border border-border bg-card/50 p-6 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1"> <div className="space-y-2">
<p className="text-sm font-medium text-foreground">CC Switch</p> <h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
<p className="text-xs text-muted-foreground"> <div className="flex items-center gap-2">
{t("common.version")}{" "} <Badge variant="outline" className="gap-1.5 bg-background">
{isLoadingVersion ? ( <span className="text-muted-foreground">
<Loader2 className="inline h-3 w-3 animate-spin" /> {t("common.version")}
) : ( </span>
`v${displayVersion}` {isLoadingVersion ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="font-medium">{`v${displayVersion}`}</span>
)}
</Badge>
{isPortable && (
<Badge variant="secondary" className="gap-1.5">
<Info className="h-3 w-3" />
{t("settings.portableMode")}
</Badge>
)} )}
</p> </div>
{isPortable ? (
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Info className="h-3 w-3" />
{t("settings.portableMode")}
</p>
) : null}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -159,6 +191,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleOpenReleaseNotes} onClick={handleOpenReleaseNotes}
className="h-9"
> >
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" />
{t("settings.releaseNotes")} {t("settings.releaseNotes")}
@@ -168,7 +201,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm" size="sm"
onClick={handleCheckUpdate} onClick={handleCheckUpdate}
disabled={isChecking || isDownloading} disabled={isChecking || isDownloading}
className="min-w-[140px]" className="min-w-[140px] h-9"
> >
{isDownloading ? ( {isDownloading ? (
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
@@ -194,18 +227,71 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div> </div>
</div> </div>
{hasUpdate && updateInfo ? ( {hasUpdate && updateInfo && (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground"> <div className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm">
<p> <p className="font-medium text-primary mb-1">
{t("settings.updateAvailable", { {t("settings.updateAvailable", {
version: updateInfo.availableVersion, version: updateInfo.availableVersion,
})} })}
</p> </p>
{updateInfo.notes ? ( {updateInfo.notes && (
<p className="mt-1 line-clamp-3">{updateInfo.notes}</p> <p className="text-muted-foreground line-clamp-3 leading-relaxed">
) : null} {updateInfo.notes}
</p>
)}
</div> </div>
) : null} )}
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground px-1">
</h4>
<div className="grid gap-3 sm:grid-cols-3">
{isLoadingTools
? Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-20 rounded-xl border border-border bg-card/50 animate-pulse"
/>
))
: toolVersions.map((tool) => (
<div
key={tool.name}
className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize">
{tool.name}
</span>
</div>
{tool.version ? (
<div className="flex items-center gap-1.5">
{tool.latest_version &&
tool.version !== tool.latest_version && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
Update: {tool.latest_version}
</span>
)}
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
</div>
<div className="flex flex-col gap-0.5">
<div
className="text-xs font-mono truncate"
title={tool.version || tool.error || "Unknown"}
>
{tool.version ? tool.version : tool.error || "未安装"}
</div>
</div>
</div>
))}
</div>
</div> </div>
</section> </section>
); );
@@ -50,7 +50,7 @@ export function DirectorySettings({
<Input <Input
value={appConfigDir ?? resolvedDirs.appConfig ?? ""} value={appConfigDir ?? resolvedDirs.appConfig ?? ""}
placeholder={t("settings.browsePlaceholderApp")} placeholder={t("settings.browsePlaceholderApp")}
className="font-mono text-xs" className="text-xs"
onChange={(event) => onAppConfigChange(event.target.value)} onChange={(event) => onAppConfigChange(event.target.value)}
/> />
<Button <Button
@@ -161,7 +161,7 @@ function DirectoryInput({
<Input <Input
value={displayValue} value={displayValue}
placeholder={placeholder} placeholder={placeholder}
className="font-mono text-xs" className="text-xs"
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
/> />
<Button <Button
+241 -42
View File
@@ -1,5 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save } from "lucide-react"; import {
Loader2,
Save,
FolderSearch,
Activity,
Coins,
Database,
Server,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Dialog, Dialog,
@@ -8,6 +16,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
@@ -20,11 +34,15 @@ import { AboutSection } from "@/components/settings/AboutSection";
import { ProxyPanel } from "@/components/proxy"; import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport"; import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings"; import type { SettingsFormState } from "@/hooks/useSettings";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useProxyStatus } from "@/hooks/useProxyStatus";
interface SettingsDialogProps { interface SettingsDialogProps {
open: boolean; open: boolean;
@@ -154,6 +172,26 @@ export function SettingsPage({
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]); const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
const {
isRunning,
start: startProxy,
stop: stopProxy,
isPending: isProxyPending,
} = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true);
const handleToggleProxy = async (checked: boolean) => {
try {
if (!checked) {
await stopProxy();
} else {
await startProxy();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
}
};
return ( return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6"> <div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? ( {isBusy ? (
@@ -198,61 +236,225 @@ export function SettingsPage({
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6"> <TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
{settings ? ( {settings ? (
<> <div className="space-y-4">
<DirectorySettings <Accordion
appConfigDir={appConfigDir} type="multiple"
resolvedDirs={resolvedDirs} defaultValue={[]}
onAppConfigChange={updateAppConfigDir} className="w-full space-y-4"
onBrowseAppConfig={browseAppConfigDir} >
onResetAppConfig={resetAppConfigDir} <AccordionItem
claudeDir={settings.claudeConfigDir} value="directory"
codexDir={settings.codexConfigDir} className="rounded-xl glass-card overflow-hidden"
geminiDir={settings.geminiConfigDir} >
onDirectoryChange={updateDirectory} <AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
onBrowseDirectory={browseDirectory} <div className="flex items-center gap-3">
onResetDirectory={resetDirectory} <FolderSearch className="h-5 w-5 text-primary" />
/> <div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
ClaudeCodex Gemini
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
</AccordionContent>
</AccordionItem>
{/* 代理服务面板 */} <AccordionItem
<ProxyPanel /> value="proxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex flex-1 items-center justify-between pr-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
<div
className="flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning ? "运行中" : "已停止"}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
/>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
<ProxyPanel />
</AccordionContent>
</AccordionItem>
{/* 模型定价配置 */} <AccordionItem
<PricingConfigPanel /> value="test"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-indigo-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
使
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ModelTestConfigPanel />
</AccordionContent>
</AccordionItem>
{/* 模型测试配置 */} <AccordionItem
<ModelTestConfigPanel /> value="failover"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex flex-1 items-center justify-between pr-4">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
<div
className="flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
{/* Removed status text as requested */}
<Switch
checked={failoverEnabled}
onCheckedChange={setFailoverEnabled}
/>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<AutoFailoverConfigPanel
enabled={failoverEnabled}
onEnabledChange={setFailoverEnabled}
/>
</AccordionContent>
</AccordionItem>
<ImportExportSection <AccordionItem
status={importStatus} value="pricing"
selectedFile={selectedFile} className="rounded-xl glass-card overflow-hidden"
errorMessage={errorMessage} >
backupId={backupId} <AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
isImporting={isImporting} <div className="flex items-center gap-3">
onSelectFile={selectImportFile} <Coins className="h-5 w-5 text-yellow-500" />
onImport={importConfig} <div className="text-left">
onExport={exportConfig} <h3 className="text-base font-semibold">
onClear={clearSelection}
/> </h3>
<div className="pt-6 border-t border-gray-200 dark:border-white/10"> <p className="text-sm text-muted-foreground font-normal">
Token
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="data"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-blue-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="pt-4">
<Button <Button
onClick={handleSave} onClick={handleSave}
className="w-full" className="w-full h-12 text-base font-medium"
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? ( {isSaving ? (
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
{t("settings.saving")} {t("settings.saving")}
</span> </span>
) : ( ) : (
<> <>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-5 w-5" />
{t("common.save")} {t("common.save")}
</> </>
)} )}
</Button> </Button>
</div> </div>
</> </div>
) : null} ) : null}
</TabsContent> </TabsContent>
@@ -271,10 +473,7 @@ export function SettingsPage({
open={showRestartPrompt} open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()} onOpenChange={(open) => !open && handleRestartLater()}
> >
<DialogContent <DialogContent zIndex="alert" className="max-w-md glass border-border">
zIndex="alert"
className="max-w-md glass border-white/10"
>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle> <DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -287,7 +486,7 @@ export function SettingsPage({
<Button <Button
variant="ghost" variant="ghost"
onClick={handleRestartLater} onClick={handleRestartLater}
className="hover:bg-white/5" className="hover:bg-muted/50"
> >
{t("settings.restartLater")} {t("settings.restartLater")}
</Button> </Button>
+44 -31
View File
@@ -1,6 +1,7 @@
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings"; import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power } from "lucide-react";
interface WindowSettingsProps { interface WindowSettingsProps {
settings: SettingsFormState; settings: SettingsFormState;
@@ -12,40 +13,46 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<header className="space-y-1"> <div className="flex items-center gap-2 pb-2 border-b border-border/40">
<AppWindow className="h-4 w-4 text-primary" />
<h3 className="text-sm font-medium">{t("settings.windowBehavior")}</h3> <h3 className="text-sm font-medium">{t("settings.windowBehavior")}</h3>
<p className="text-xs text-muted-foreground"> </div>
{t("settings.windowBehaviorHint")}
</p>
</header>
<ToggleRow <div className="space-y-3">
title={t("settings.launchOnStartup")} <ToggleRow
description={t("settings.launchOnStartupDescription")} icon={<Power className="h-4 w-4 text-orange-500" />}
checked={!!settings.launchOnStartup} title={t("settings.launchOnStartup")}
onCheckedChange={(value) => onChange({ launchOnStartup: value })} description={t("settings.launchOnStartupDescription")}
/> checked={!!settings.launchOnStartup}
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<ToggleRow <ToggleRow
title={t("settings.minimizeToTray")} icon={<AppWindow className="h-4 w-4 text-blue-500" />}
description={t("settings.minimizeToTrayDescription")} title={t("settings.minimizeToTray")}
checked={settings.minimizeToTrayOnClose} description={t("settings.minimizeToTrayDescription")}
onCheckedChange={(value) => onChange({ minimizeToTrayOnClose: value })} checked={settings.minimizeToTrayOnClose}
/> onCheckedChange={(value) =>
onChange({ minimizeToTrayOnClose: value })
}
/>
<ToggleRow <ToggleRow
title={t("settings.enableClaudePluginIntegration")} icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
description={t("settings.enableClaudePluginIntegrationDescription")} title={t("settings.enableClaudePluginIntegration")}
checked={!!settings.enableClaudePluginIntegration} description={t("settings.enableClaudePluginIntegrationDescription")}
onCheckedChange={(value) => checked={!!settings.enableClaudePluginIntegration}
onChange({ enableClaudePluginIntegration: value }) onCheckedChange={(value) =>
} onChange({ enableClaudePluginIntegration: value })
/> }
/>
</div>
</section> </section>
); );
} }
interface ToggleRowProps { interface ToggleRowProps {
icon: React.ReactNode;
title: string; title: string;
description?: string; description?: string;
checked: boolean; checked: boolean;
@@ -53,18 +60,24 @@ interface ToggleRowProps {
} }
function ToggleRow({ function ToggleRow({
icon,
title, title,
description, description,
checked, checked,
onCheckedChange, onCheckedChange,
}: ToggleRowProps) { }: ToggleRowProps) {
return ( return (
<div className="flex items-start justify-between gap-4 rounded-lg border border-border-default p-4"> <div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
<div className="space-y-1"> <div className="flex items-center gap-3">
<p className="text-sm font-medium leading-none">{title}</p> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
{description ? ( {icon}
<p className="text-xs text-muted-foreground">{description}</p> </div>
) : null} <div className="space-y-1">
<p className="text-sm font-medium leading-none">{title}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
</div> </div>
<Switch <Switch
checked={checked} checked={checked}
+5 -14
View File
@@ -19,17 +19,11 @@ import { RefreshCw, Search } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { SkillCard } from "./SkillCard"; import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel"; import { RepoManagerPanel } from "./RepoManagerPanel";
import { import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
skillsApi,
type Skill,
type SkillRepo,
type AppType,
} from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser"; import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps { interface SkillsPageProps {
onClose?: () => void; onClose?: () => void;
initialApp?: AppType;
} }
export interface SkillsPageHandle { export interface SkillsPageHandle {
@@ -38,7 +32,7 @@ export interface SkillsPageHandle {
} }
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>( export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ onClose: _onClose, initialApp = "claude" }, ref) => { ({ onClose: _onClose }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]); const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]); const [repos, setRepos] = useState<SkillRepo[]>([]);
@@ -48,13 +42,11 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const [filterStatus, setFilterStatus] = useState< const [filterStatus, setFilterStatus] = useState<
"all" | "installed" | "uninstalled" "all" | "installed" | "uninstalled"
>("all"); >("all");
// 使用 initialApp,不允许切换
const selectedApp = initialApp;
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try { try {
setLoading(true); setLoading(true);
const data = await skillsApi.getAll(selectedApp); const data = await skillsApi.getAll();
setSkills(data); setSkills(data);
if (afterLoad) { if (afterLoad) {
afterLoad(data); afterLoad(data);
@@ -92,7 +84,6 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
useEffect(() => { useEffect(() => {
Promise.all([loadSkills(), loadRepos()]); Promise.all([loadSkills(), loadRepos()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -102,7 +93,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const handleInstall = async (directory: string) => { const handleInstall = async (directory: string) => {
try { try {
await skillsApi.install(directory, selectedApp); await skillsApi.install(directory);
toast.success(t("skills.installSuccess", { name: directory })); toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills(); await loadSkills();
} catch (error) { } catch (error) {
@@ -131,7 +122,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const handleUninstall = async (directory: string) => { const handleUninstall = async (directory: string) => {
try { try {
await skillsApi.uninstall(directory, selectedApp); await skillsApi.uninstall(directory);
toast.success(t("skills.uninstallSuccess", { name: directory })); toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills(); await loadSkills();
} catch (error) { } catch (error) {
+56
View File
@@ -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<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+1 -1
View File
@@ -18,7 +18,7 @@ export function ModelStatsTable() {
} }
return ( return (
<div className="rounded-md bg-card/60 shadow-sm"> <div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
+111 -159
View File
@@ -1,17 +1,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; 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 { toast } from "sonner";
import { import {
getModelTestConfig, getModelTestConfig,
@@ -21,7 +14,6 @@ import {
export function ModelTestConfigPanel() { export function ModelTestConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -66,160 +58,120 @@ export function ModelTestConfigPanel() {
if (isLoading) { if (isLoading) {
return ( return (
<Card className="border rounded-lg"> <div className="flex items-center justify-center p-4">
<CardHeader <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
className="cursor-pointer" </div>
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<ChevronRight className="h-4 w-4" />
<CardTitle className="text-base">
{t("modelTest.configTitle", "模型测试配置")}
</CardTitle>
</div>
</CardHeader>
</Card>
); );
} }
return ( return (
<Card className="border rounded-lg"> <div className="space-y-4">
<CardHeader {error && (
className="cursor-pointer select-none" <Alert variant="destructive">
onClick={() => setIsExpanded(!isExpanded)} <AlertDescription>{error}</AlertDescription>
> </Alert>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<div>
<CardTitle className="text-base">
{t("modelTest.configTitle", "模型测试配置")}
</CardTitle>
{!isExpanded && (
<CardDescription className="mt-1">
{t(
"modelTest.configDesc",
"配置模型测试使用的默认模型和提示词",
)}
</CardDescription>
)}
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="claudeModel">
{t("modelTest.claudeModel", "Claude 测试模型")}
</Label>
<Input
id="claudeModel"
value={config.claudeModel}
onChange={(e) =>
setConfig({ ...config, claudeModel: e.target.value })
}
placeholder="claude-haiku-4-5-20251001"
/>
</div>
<div className="space-y-2">
<Label htmlFor="codexModel">
{t("modelTest.codexModel", "Codex 测试模型")}
</Label>
<Input
id="codexModel"
value={config.codexModel}
onChange={(e) =>
setConfig({ ...config, codexModel: e.target.value })
}
placeholder="gpt-5.1-low"
/>
</div>
<div className="space-y-2">
<Label htmlFor="geminiModel">
{t("modelTest.geminiModel", "Gemini 测试模型")}
</Label>
<Input
id="geminiModel"
value={config.geminiModel}
onChange={(e) =>
setConfig({ ...config, geminiModel: e.target.value })
}
placeholder="gemini-3-pro-low"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="testPrompt">
{t("modelTest.testPrompt", "测试提示词")}
</Label>
<Input
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="ping"
/>
<p className="text-xs text-muted-foreground">
{t(
"modelTest.testPromptHint",
"发送给模型的测试消息,建议使用简短内容以减少 token 消耗",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="timeoutSecs">
{t("modelTest.timeout", "超时时间(秒)")}
</Label>
<Input
id="timeoutSecs"
type="number"
min={5}
max={60}
value={config.timeoutSecs}
onChange={(e) =>
setConfig({
...config,
timeoutSecs: parseInt(e.target.value) || 15,
})
}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", "保存中...")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", "保存")}
</>
)}
</Button>
</div>
</CardContent>
)} )}
</Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="claudeModel">
{t("modelTest.claudeModel", "Claude 测试模型")}
</Label>
<Input
id="claudeModel"
value={config.claudeModel}
onChange={(e) =>
setConfig({ ...config, claudeModel: e.target.value })
}
placeholder="claude-haiku-4-5-20251001"
/>
</div>
<div className="space-y-2">
<Label htmlFor="codexModel">
{t("modelTest.codexModel", "Codex 测试模型")}
</Label>
<Input
id="codexModel"
value={config.codexModel}
onChange={(e) =>
setConfig({ ...config, codexModel: e.target.value })
}
placeholder="gpt-5.1-low"
/>
</div>
<div className="space-y-2">
<Label htmlFor="geminiModel">
{t("modelTest.geminiModel", "Gemini 测试模型")}
</Label>
<Input
id="geminiModel"
value={config.geminiModel}
onChange={(e) =>
setConfig({ ...config, geminiModel: e.target.value })
}
placeholder="gemini-3-pro-low"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="testPrompt">
{t("modelTest.testPrompt", "测试提示词")}
</Label>
<Input
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="ping"
/>
<p className="text-xs text-muted-foreground">
{t(
"modelTest.testPromptHint",
"发送给模型的测试消息,建议使用简短内容以减少 token 消耗",
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="timeoutSecs">
{t("modelTest.timeout", "超时时间(秒)")}
</Label>
<Input
id="timeoutSecs"
type="number"
min={5}
max={60}
value={config.timeoutSecs}
onChange={(e) =>
setConfig({
...config,
timeoutSecs: parseInt(e.target.value) || 15,
})
}
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("common.saving", "保存中...")}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save", "保存")}
</>
)}
</Button>
</div>
</div>
); );
} }
+101 -136
View File
@@ -1,12 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
Table, Table,
TableBody, TableBody,
@@ -110,136 +104,107 @@ export function PricingConfigPanel() {
} }
return ( return (
<Card className="border rounded-lg"> <div className="space-y-4">
<CardHeader <div className="flex items-center justify-between mb-4">
className="cursor-pointer select-none" <h4 className="text-sm font-medium text-muted-foreground">
onClick={() => setIsExpanded(!isExpanded)} {t("usage.modelPricingDesc", "配置各模型的 Token 成本")} ()
> </h4>
<div className="flex items-center justify-between"> <Button
<div className="flex items-center gap-2"> onClick={(e) => {
{isExpanded ? ( e.stopPropagation();
<ChevronDown className="h-4 w-4 text-muted-foreground" /> handleAddNew();
) : ( }}
<ChevronRight className="h-4 w-4 text-muted-foreground" /> size="sm"
)} >
<div> <Plus className="mr-1 h-4 w-4" />
<CardTitle className="text-base"> {t("common.add", "新增")}
{t("usage.modelPricing", "模型定价")} </Button>
{pricing && pricing.length > 0 && ( </div>
<span className="ml-2 text-sm font-normal text-muted-foreground">
({pricing.length})
</span>
)}
</CardTitle>
{!isExpanded && (
<CardDescription className="mt-1">
{t(
"usage.modelPricingDesc",
"配置各模型的 Token 成本(每百万 tokens 的 USD 价格,支持 * 与 ? 通配)",
)}
</CardDescription>
)}
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleAddNew();
}}
size="sm"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add", "新增")}
</Button>
</div>
</CardHeader>
{isExpanded && ( <div className="space-y-4">
<CardContent> {!pricing || pricing.length === 0 ? (
{!pricing || pricing.length === 0 ? ( <Alert>
<Alert> <AlertDescription>
<AlertDescription> {t(
{t( "usage.noPricingData",
"usage.noPricingData", '暂无定价数据。点击"新增"添加模型定价配置。',
'暂无定价数据。点击"新增"添加模型定价配置。', )}
)} </AlertDescription>
</AlertDescription> </Alert>
</Alert> ) : (
) : ( <div className="rounded-md bg-card/60 shadow-sm">
<div className="rounded-md bg-card/60 shadow-sm"> <Table>
<Table> <TableHeader>
<TableHeader> <TableRow>
<TableRow> <TableHead>{t("usage.model", "模型")}</TableHead>
<TableHead>{t("usage.model", "模型")}</TableHead> <TableHead>{t("usage.displayName", "显示名称")}</TableHead>
<TableHead>{t("usage.displayName", "显示名称")}</TableHead> <TableHead className="text-right">
<TableHead className="text-right"> {t("usage.inputCost", "输入成本")}
{t("usage.inputCost", "输入成本")} </TableHead>
</TableHead> <TableHead className="text-right">
<TableHead className="text-right"> {t("usage.outputCost", "输出成本")}
{t("usage.outputCost", "输出成本")} </TableHead>
</TableHead> <TableHead className="text-right">
<TableHead className="text-right"> {t("usage.cacheReadCost", "缓存读取")}
{t("usage.cacheReadCost", "缓存读取")} </TableHead>
</TableHead> <TableHead className="text-right">
<TableHead className="text-right"> {t("usage.cacheWriteCost", "缓存写入")}
{t("usage.cacheWriteCost", "缓存写入")} </TableHead>
</TableHead> <TableHead className="text-right">
<TableHead className="text-right"> {t("common.actions", "操作")}
{t("common.actions", "操作")} </TableHead>
</TableHead> </TableRow>
</TableHeader>
<TableBody>
{pricing.map((model) => (
<TableRow key={model.modelId}>
<TableCell className="font-mono text-sm">
{model.modelId}
</TableCell>
<TableCell>{model.displayName}</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.inputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.outputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheReadCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheCreationCostPerMillion}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setIsAddingNew(false);
setEditingModel(model);
}}
title={t("common.edit", "编辑")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete", "删除")}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
</TableHeader> ))}
<TableBody> </TableBody>
{pricing.map((model) => ( </Table>
<TableRow key={model.modelId}> </div>
<TableCell className="font-mono text-sm"> )}
{model.modelId} </div>
</TableCell>
<TableCell>{model.displayName}</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.inputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.outputCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheReadCostPerMillion}
</TableCell>
<TableCell className="text-right font-mono text-sm">
${model.cacheCreationCostPerMillion}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setIsAddingNew(false);
setEditingModel(model);
}}
title={t("common.edit", "编辑")}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteConfirm(model.modelId)}
title={t("common.delete", "删除")}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
)}
{editingModel && ( {editingModel && (
<PricingEditModal <PricingEditModal
@@ -284,6 +249,6 @@ export function PricingConfigPanel() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Card> </div>
); );
} }
+1 -1
View File
@@ -18,7 +18,7 @@ export function ProviderStatsTable() {
} }
return ( return (
<div className="rounded-md bg-card/60 shadow-sm"> <div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
+156 -134
View File
@@ -65,123 +65,151 @@ export function RequestLogTable() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 筛选栏 */} {/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-2 rounded-md bg-card/60 p-3 shadow-sm"> <div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<Select <div className="flex flex-wrap items-center gap-3">
value={tempFilters.appType || "all"} <Select
onValueChange={(v) => value={tempFilters.appType || "all"}
setTempFilters({ onValueChange={(v) =>
...tempFilters, setTempFilters({
appType: v === "all" ? undefined : v, ...tempFilters,
}) appType: v === "all" ? undefined : v,
} })
> }
<SelectTrigger className="w-[120px]"> >
<SelectValue placeholder={t("usage.endpoint", "端点")} /> <SelectTrigger className="w-[130px] bg-background">
</SelectTrigger> <SelectValue placeholder={t("usage.endpoint", "端点")} />
<SelectContent> </SelectTrigger>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem> <SelectContent>
<SelectItem value="claude">Claude</SelectItem> <SelectItem value="all">{t("common.all", "全部端点")}</SelectItem>
<SelectItem value="codex">Codex</SelectItem> <SelectItem value="claude">Claude</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem> <SelectItem value="codex">Codex</SelectItem>
</SelectContent> <SelectItem value="gemini">Gemini</SelectItem>
</Select> </SelectContent>
</Select>
<Select <Select
value={tempFilters.statusCode?.toString() || "all"} value={tempFilters.statusCode?.toString() || "all"}
onValueChange={(v) => onValueChange={(v) =>
setTempFilters({ setTempFilters({
...tempFilters, ...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v), statusCode: v === "all" ? undefined : parseInt(v),
}) })
} }
> >
<SelectTrigger className="w-[120px]"> <SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.status", "状态码")} /> <SelectValue placeholder={t("usage.status", "状态码")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem> <SelectItem value="all">{t("common.all", "全部状态")}</SelectItem>
<SelectItem value="200">200</SelectItem> <SelectItem value="200">200 OK</SelectItem>
<SelectItem value="400">400</SelectItem> <SelectItem value="400">400 Bad Request</SelectItem>
<SelectItem value="401">401</SelectItem> <SelectItem value="401">401 Unauthorized</SelectItem>
<SelectItem value="429">429</SelectItem> <SelectItem value="429">429 Rate Limit</SelectItem>
<SelectItem value="500">500</SelectItem> <SelectItem value="500">500 Server Error</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Input <div className="flex items-center gap-2 flex-1 min-w-[300px]">
placeholder={t("usage.provider", "供应商名称")} <div className="relative flex-1">
className="w-[140px]" <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
value={tempFilters.providerName || ""} <Input
onChange={(e) => placeholder={t("usage.provider", "搜索供应商...")}
setTempFilters({ className="pl-9 bg-background"
...tempFilters, value={tempFilters.providerName || ""}
providerName: e.target.value || undefined, onChange={(e) =>
}) setTempFilters({
} ...tempFilters,
/> providerName: e.target.value || undefined,
})
}
/>
</div>
<Input
placeholder={t("usage.model", "搜索模型...")}
className="w-[180px] bg-background"
value={tempFilters.model || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
model: e.target.value || undefined,
})
}
/>
</div>
</div>
<Input <div className="flex flex-wrap items-center justify-between gap-3">
placeholder={t("usage.model", "模型名称")} <div className="flex items-center gap-2 text-sm text-muted-foreground">
className="w-[140px]" <span className="whitespace-nowrap">:</span>
value={tempFilters.model || ""} <Input
onChange={(e) => type="datetime-local"
setTempFilters({ className="h-8 w-[200px] bg-background"
...tempFilters, value={
model: e.target.value || undefined, tempFilters.startDate
}) ? new Date(tempFilters.startDate * 1000)
} .toISOString()
/> .slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
startDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<span>-</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
tempFilters.endDate
? new Date(tempFilters.endDate * 1000)
.toISOString()
.slice(0, 16)
: ""
}
onChange={(e) =>
setTempFilters({
...tempFilters,
endDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
</div>
<Input <div className="flex items-center gap-2 ml-auto">
type="datetime-local" <Button
className="w-[180px]" size="sm"
value={ variant="default"
tempFilters.startDate onClick={handleSearch}
? new Date(tempFilters.startDate * 1000) className="h-8"
.toISOString() >
.slice(0, 16) <Search className="mr-2 h-3.5 w-3.5" />
: "" {t("common.search", "查询")}
} </Button>
onChange={(e) => <Button
setTempFilters({ size="sm"
...tempFilters, variant="outline"
startDate: e.target.value onClick={handleReset}
? Math.floor(new Date(e.target.value).getTime() / 1000) className="h-8"
: undefined, >
}) <X className="mr-2 h-3.5 w-3.5" />
} {t("common.reset", "重置")}
/> </Button>
<Button
<Input size="sm"
type="datetime-local" variant="ghost"
className="w-[180px]" onClick={handleRefresh}
value={ className="h-8 px-2"
tempFilters.endDate >
? new Date(tempFilters.endDate * 1000).toISOString().slice(0, 16) <RefreshCw className="h-4 w-4" />
: "" </Button>
} </div>
onChange={(e) =>
setTempFilters({
...tempFilters,
endDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<div className="ml-auto flex gap-2">
<Button size="sm" onClick={handleSearch}>
<Search className="mr-1 h-4 w-4" />
{t("common.search", "查询")}
</Button>
<Button size="sm" variant="outline" onClick={handleReset}>
<X className="mr-1 h-4 w-4" />
{t("common.reset", "重置")}
</Button>
<Button size="sm" variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
@@ -189,40 +217,34 @@ export function RequestLogTable() {
<div className="h-[400px] animate-pulse rounded bg-gray-100" /> <div className="h-[400px] animate-pulse rounded bg-gray-100" />
) : ( ) : (
<> <>
<div className="rounded-md bg-card/60 shadow-sm overflow-x-auto"> <div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="whitespace-nowrap"> <TableHead>{t("usage.time", "时间")}</TableHead>
{t("usage.time", "时间")} <TableHead>{t("usage.provider", "供应商")}</TableHead>
</TableHead> <TableHead className="min-w-[280px]">
<TableHead className="whitespace-nowrap">
{t("usage.provider", "供应商")}
</TableHead>
<TableHead className="min-w-[280px] whitespace-nowrap">
{t("usage.billingModel", "计费模型")} {t("usage.billingModel", "计费模型")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right">
{t("usage.inputTokens", "输入")} {t("usage.inputTokens", "输入")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right">
{t("usage.outputTokens", "输出")} {t("usage.outputTokens", "输出")}
</TableHead> </TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap"> <TableHead className="text-right min-w-[90px]">
{t("usage.cacheReadTokens", "缓存读取")}
</TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens", "缓存写入")} {t("usage.cacheCreationTokens", "缓存写入")}
</TableHead> </TableHead>
<TableHead className="text-right whitespace-nowrap"> <TableHead className="text-right min-w-[90px]">
{t("usage.cacheReadTokens", "缓存读取")}
</TableHead>
<TableHead className="text-right">
{t("usage.totalCost", "成本")} {t("usage.totalCost", "成本")}
</TableHead> </TableHead>
<TableHead className="text-center min-w-[140px] whitespace-nowrap"> <TableHead className="text-center min-w-[140px]">
{t("usage.timingInfo", "用时/首字")} {t("usage.timingInfo", "用时/首字")}
</TableHead> </TableHead>
<TableHead className="whitespace-nowrap"> <TableHead>{t("usage.status", "状态")}</TableHead>
{t("usage.status", "状态")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -258,10 +280,10 @@ export function RequestLogTable() {
{log.outputTokens.toLocaleString()} {log.outputTokens.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{log.cacheReadTokens.toLocaleString()} {log.cacheCreationTokens.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{log.cacheCreationTokens.toLocaleString()} {log.cacheReadTokens.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
${parseFloat(log.totalCostUsd).toFixed(6)} ${parseFloat(log.totalCostUsd).toFixed(6)}
+77 -36
View File
@@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { UsageSummaryCards } from "./UsageSummaryCards"; import { UsageSummaryCards } from "./UsageSummaryCards";
import { UsageTrendChart } from "./UsageTrendChart"; import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable"; import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable"; import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable"; import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage"; import type { TimeRange } from "@/types/usage";
import { motion } from "framer-motion";
import { BarChart3, ListFilter, Activity } from "lucide-react";
export function UsageDashboard() { export function UsageDashboard() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -16,50 +17,90 @@ export function UsageDashboard() {
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
return ( return (
<div className="space-y-6"> <motion.div
<div className="flex items-center justify-end"> initial={{ opacity: 0, y: 10 }}
<select animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-8 pb-8"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold">{t("usage.title", "使用统计")}</h2>
<p className="text-sm text-muted-foreground">
{t("usage.subtitle", "查看 AI 模型的使用情况和成本统计")}
</p>
</div>
<Tabs
value={timeRange} value={timeRange}
onChange={(e) => setTimeRange(e.target.value as TimeRange)} onValueChange={(v) => setTimeRange(v as TimeRange)}
className="rounded-md border px-3 py-1.5 text-sm" className="w-full sm:w-auto"
> >
<option value="1d">{t("usage.today", "今天")}</option> <TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
<option value="7d">{t("usage.last7days", "过去 7 天")}</option> <TabsTrigger
<option value="30d">{t("usage.last30days", "过去 30 天")}</option> value="1d"
</select> className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.today", "24小时")}
</TabsTrigger>
<TabsTrigger
value="7d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last7days", "7天")}
</TabsTrigger>
<TabsTrigger
value="30d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{t("usage.last30days", "30天")}
</TabsTrigger>
</TabsList>
</Tabs>
</div> </div>
<UsageSummaryCards days={days} /> <UsageSummaryCards days={days} />
<Card className="border-none bg-transparent p-0 shadow-none"> <UsageTrendChart days={days} />
<UsageTrendChart days={days} />
</Card>
<Tabs defaultValue="logs" className="w-full"> <div className="space-y-4">
<TabsList> <Tabs defaultValue="logs" className="w-full">
<TabsTrigger value="logs"> <div className="flex items-center justify-between mb-4">
{t("usage.requestLogs", "请求日志")} <TabsList className="bg-muted/50">
</TabsTrigger> <TabsTrigger value="logs" className="gap-2">
<TabsTrigger value="providers"> <ListFilter className="h-4 w-4" />
{t("usage.providerStats", "Provider 统计")} {t("usage.requestLogs", "请求日志")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="models"> <TabsTrigger value="providers" className="gap-2">
{t("usage.modelStats", "模型统计")} <Activity className="h-4 w-4" />
</TabsTrigger> {t("usage.providerStats", "Provider 统计")}
</TabsList> </TabsTrigger>
<TabsTrigger value="models" className="gap-2">
<BarChart3 className="h-4 w-4" />
{t("usage.modelStats", "模型统计")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="logs" className="mt-4"> <motion.div
<RequestLogTable /> initial={{ opacity: 0, y: 10 }}
</TabsContent> animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable />
</TabsContent>
<TabsContent value="providers" className="mt-4"> <TabsContent value="providers" className="mt-0">
<ProviderStatsTable /> <ProviderStatsTable />
</TabsContent> </TabsContent>
<TabsContent value="models" className="mt-4"> <TabsContent value="models" className="mt-0">
<ModelStatsTable /> <ModelStatsTable />
</TabsContent> </TabsContent>
</Tabs> </motion.div>
</div> </Tabs>
</div>
</motion.div>
); );
} }
+141 -85
View File
@@ -1,6 +1,9 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 { useUsageSummary } from "@/lib/query/usage";
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
import { motion } from "framer-motion";
interface UsageSummaryCardsProps { interface UsageSummaryCardsProps {
days: number; days: number;
@@ -8,29 +11,118 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
const { t } = useTranslation(); 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 { data: summary, isLoading } = useUsageSummary(startDate, endDate);
const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFloat(summary?.totalCost || "0").toFixed(4); const stats = useMemo(() => {
const totalInputTokens = summary?.totalInputTokens ?? 0; const totalRequests = summary?.totalRequests ?? 0;
const totalOutputTokens = summary?.totalOutputTokens ?? 0; const totalCost = parseFloat(summary?.totalCost || "0");
const totalTokens = totalInputTokens + totalOutputTokens;
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0; const inputTokens = summary?.totalInputTokens ?? 0;
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0; const outputTokens = summary?.totalOutputTokens ?? 0;
const totalCacheTokens = cacheWriteTokens + cacheReadTokens; 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: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>Input</span>
<span className="text-foreground/80">
{(inputTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>Output</span>
<span className="text-foreground/80">
{(outputTokens / 1000).toFixed(1)}k
</span>
</div>
</div>
),
},
{
title: t("usage.cacheTokens", "缓存 Token"),
value: totalCacheTokens.toLocaleString(),
icon: Database,
color: "text-orange-500",
bg: "bg-orange-500/10",
subValue: (
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
<div className="flex justify-between items-center">
<span>Write</span>
<span className="text-foreground/80">
{(cacheWriteTokens / 1000).toFixed(1)}k
</span>
</div>
<div className="flex justify-between items-center">
<span>Read</span>
<span className="text-foreground/80">
{(cacheReadTokens / 1000).toFixed(1)}k
</span>
</div>
</div>
),
},
];
}, [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) { if (isLoading) {
return ( return (
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<Card key={i}> <Card
<CardHeader className="pb-2"> key={i}
<div className="h-4 w-24 animate-pulse rounded bg-gray-200" /> className="border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm"
</CardHeader> >
<CardContent> <CardContent className="p-6 flex items-center justify-center min-h-[160px]">
<div className="h-8 w-32 animate-pulse rounded bg-gray-200" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
</CardContent> </CardContent>
</Card> </Card>
))} ))}
@@ -39,75 +131,39 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
} }
return ( return (
<div className="grid gap-4 md:grid-cols-4"> <motion.div
<Card> variants={container}
<CardHeader className="pb-2"> initial="hidden"
<CardTitle className="text-sm font-medium text-muted-foreground"> animate="show"
{t("usage.totalRequests", "总请求数")} className="grid gap-4 md:grid-cols-4"
</CardTitle> >
</CardHeader> {stats.map((stat, i) => (
<CardContent> <motion.div key={i} variants={item}>
<div className="text-2xl font-bold"> <Card className="relative h-full overflow-hidden border border-border/50 bg-gradient-to-br from-card/50 to-background/50 backdrop-blur-xl hover:from-card/60 hover:to-background/60 transition-all shadow-sm">
{totalRequests.toLocaleString()} <CardContent className="p-5">
</div> <div className="flex items-start justify-between mb-2">
</CardContent> <p className="text-sm font-medium text-muted-foreground">
</Card> {stat.title}
</p>
<div className={`p-2 rounded-lg ${stat.bg}`}>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</div>
</div>
<Card> <div className="space-y-1">
<CardHeader className="pb-2"> <h3 className="text-2xl font-bold truncate" title={stat.value}>
<CardTitle className="text-sm font-medium text-muted-foreground"> {stat.value}
{t("usage.totalCost", "总成本")} </h3>
</CardTitle> </div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${totalCost}</div>
</CardContent>
</Card>
<Card> {stat.subValue || (
<CardHeader className="pb-2"> /* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */
<CardTitle className="text-sm font-medium text-muted-foreground"> <div className="mt-3 pt-3 border-t border-transparent h-[52px]"></div>
{t("usage.totalTokens", "总 Token 数")} )}
</CardTitle> </CardContent>
</CardHeader> </Card>
<CardContent> </motion.div>
<div className="text-2xl font-bold"> ))}
{totalTokens.toLocaleString()} </motion.div>
</div>
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
<div>
{t("usage.inputTokens", "输入")}:{" "}
{totalInputTokens.toLocaleString()}
</div>
<div>
{t("usage.outputTokens", "输出")}:{" "}
{totalOutputTokens.toLocaleString()}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.cacheTokens", "缓存 Token")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{totalCacheTokens.toLocaleString()}
</div>
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
<div>
{t("usage.cacheWrite", "写入")}:{" "}
{cacheWriteTokens.toLocaleString()}
</div>
<div>
{t("usage.cacheRead", "读取")}: {cacheReadTokens.toLocaleString()}
</div>
</div>
</CardContent>
</Card>
</div>
); );
} }
+124 -90
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
LineChart, AreaChart,
Line, Area,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid, CartesianGrid,
@@ -10,6 +10,7 @@ import {
Legend, Legend,
} from "recharts"; } from "recharts";
import { useUsageTrends } from "@/lib/query/usage"; import { useUsageTrends } from "@/lib/query/usage";
import { Loader2 } from "lucide-react";
interface UsageTrendChartProps { interface UsageTrendChartProps {
days: number; days: number;
@@ -20,7 +21,11 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
const { data: trends, isLoading } = useUsageTrends(days); const { data: trends, isLoading } = useUsageTrends(days);
if (isLoading) { if (isLoading) {
return <div className="h-[320px] animate-pulse rounded bg-gray-100" />; return (
<div className="flex h-[350px] items-center justify-center rounded-xl bg-card/40 border border-border/50">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/30" />
</div>
);
} }
const isToday = days === 1; const isToday = days === 1;
@@ -38,8 +43,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
hour: pointDate.getHours(), hour: pointDate.getHours(),
inputTokens: stat.totalInputTokens, inputTokens: stat.totalInputTokens,
outputTokens: stat.totalOutputTokens, outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost), cost: parseFloat(stat.totalCost),
}; };
}) || []; }) || [];
@@ -56,8 +59,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
label: `${hour.toString().padStart(2, "0")}:00`, label: `${hour.toString().padStart(2, "0")}:00`,
inputTokens: bucket?.inputTokens ?? 0, inputTokens: bucket?.inputTokens ?? 0,
outputTokens: bucket?.outputTokens ?? 0, outputTokens: bucket?.outputTokens ?? 0,
cacheCreationTokens: bucket?.cacheCreationTokens ?? 0,
cacheReadTokens: bucket?.cacheReadTokens ?? 0,
cost: bucket?.cost ?? 0, cost: bucket?.cost ?? 0,
}; };
}); });
@@ -65,96 +66,129 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
const displayData = isToday ? hourlyData : chartData; const displayData = isToday ? hourlyData : chartData;
const rangeLabel = isToday const CustomTooltip = ({ active, payload, label }: any) => {
? t("usage.rangeToday", "今天 (按小时)") if (active && payload && payload.length) {
: days === 7 return (
? t("usage.rangeLast7Days", "过去 7 天") <div className="rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-md">
: t("usage.rangeLast30Days", "过去 30 天"); <p className="mb-2 font-medium">{label}</p>
{payload.map((entry: any, index: number) => (
<div
key={index}
className="flex items-center gap-2 text-sm"
style={{ color: entry.color }}
>
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="font-medium">{entry.name}:</span>
<span>
{entry.name.includes(t("usage.cost", "成本"))
? `$${typeof entry.value === "number" ? entry.value.toFixed(6) : entry.value}`
: entry.value.toLocaleString()}
</span>
</div>
))}
</div>
);
}
return null;
};
return ( return (
<div className="space-y-4"> <div className="rounded-xl border border-border/50 bg-card/40 p-6 backdrop-blur-sm">
<div className="flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
{t("usage.trends", "使用趋势")} {t("usage.trends", "使用趋势")}
</h3> </h3>
<p className="text-sm text-muted-foreground">{rangeLabel}</p> <p className="text-sm text-muted-foreground">
{isToday
? t("usage.rangeToday", "今天 (按小时)")
: days === 7
? t("usage.rangeLast7Days", "过去 7 天")
: t("usage.rangeLast30Days", "过去 30 天")}
</p>
</div> </div>
<ResponsiveContainer width="100%" height={320}> <div className="h-[350px] w-full">
<LineChart data={displayData}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid strokeDasharray="3 3" /> <AreaChart
<XAxis dataKey="label" /> data={displayData}
<YAxis margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
yAxisId="tokens" >
label={{ <defs>
value: t("usage.tokensAxis", "Tokens"), <linearGradient id="colorInput" x1="0" y1="0" x2="0" y2="1">
angle: -90, <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
position: "insideLeft", <stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
}} </linearGradient>
/> <linearGradient id="colorOutput" x1="0" y1="0" x2="0" y2="1">
<YAxis <stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
yAxisId="cost" <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
orientation="right" </linearGradient>
label={{ </defs>
value: t("usage.costAxis", "成本 (USD)"), <CartesianGrid
angle: 90, strokeDasharray="3 3"
position: "insideRight", vertical={false}
}} stroke="hsl(var(--border))"
/> opacity={0.4}
<Tooltip /> />
<Legend /> <XAxis
<Line dataKey="label"
yAxisId="tokens" axisLine={false}
type="monotone" tickLine={false}
dataKey="inputTokens" tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
name={t("usage.inputTokens", "输入 Tokens")} dy={10}
stroke="#2563eb" />
strokeWidth={2} <YAxis
dot={false} yAxisId="tokens"
isAnimationActive axisLine={false}
/> tickLine={false}
<Line tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
yAxisId="tokens" tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
type="monotone" />
dataKey="outputTokens" <YAxis
name={t("usage.outputTokens", "输出 Tokens")} yAxisId="cost"
stroke="#16a34a" orientation="right"
strokeWidth={2} axisLine={false}
dot={false} tickLine={false}
isAnimationActive tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
/> tickFormatter={(value) => `$${value}`}
<Line />
yAxisId="tokens" <Tooltip content={<CustomTooltip />} />
type="monotone" <Legend />
dataKey="cacheCreationTokens" <Area
name={t("usage.cacheCreationTokens", "缓存写入")} yAxisId="tokens"
stroke="#f97316" type="monotone"
strokeWidth={2} dataKey="inputTokens"
dot={false} name={t("usage.inputTokens", "输入 Tokens")}
isAnimationActive stroke="#3b82f6"
/> fillOpacity={1}
<Line fill="url(#colorInput)"
yAxisId="tokens" strokeWidth={2}
type="monotone" />
dataKey="cacheReadTokens" <Area
name={t("usage.cacheReadTokens", "缓存读取")} yAxisId="tokens"
stroke="#a855f7" type="monotone"
strokeWidth={2} dataKey="outputTokens"
dot={false} name={t("usage.outputTokens", "输出 Tokens")}
isAnimationActive stroke="#22c55e"
/> fillOpacity={1}
<Line fill="url(#colorOutput)"
yAxisId="cost" strokeWidth={2}
type="monotone" />
dataKey="cost" <Area
name={t("usage.cost", "成本")} yAxisId="cost"
stroke="#dc2626" type="monotone"
strokeWidth={2} dataKey="cost"
dot={false} name={t("usage.cost", "成本")}
isAnimationActive stroke="#f43f5e"
/> fill="none"
</LineChart> strokeWidth={2}
</ResponsiveContainer> strokeDasharray="4 4"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div> </div>
); );
} }
+1 -13
View File
@@ -9,7 +9,6 @@ import {
useUpdateProviderMutation, useUpdateProviderMutation,
useDeleteProviderMutation, useDeleteProviderMutation,
useSwitchProviderMutation, useSwitchProviderMutation,
useSetProxyTargetMutation,
} from "@/lib/query"; } from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
@@ -25,7 +24,6 @@ export function useProviderActions(activeApp: AppId) {
const updateProviderMutation = useUpdateProviderMutation(activeApp); const updateProviderMutation = useUpdateProviderMutation(activeApp);
const deleteProviderMutation = useDeleteProviderMutation(activeApp); const deleteProviderMutation = useDeleteProviderMutation(activeApp);
const switchProviderMutation = useSwitchProviderMutation(activeApp); const switchProviderMutation = useSwitchProviderMutation(activeApp);
const setProxyTargetMutation = useSetProxyTargetMutation(activeApp);
// Claude 插件同步逻辑 // Claude 插件同步逻辑
const syncClaudePlugin = useCallback( const syncClaudePlugin = useCallback(
@@ -93,14 +91,6 @@ export function useProviderActions(activeApp: AppId) {
[switchProviderMutation, syncClaudePlugin], [switchProviderMutation, syncClaudePlugin],
); );
// 设置代理目标
const setProxyTarget = useCallback(
async (provider: Provider) => {
await setProxyTargetMutation.mutateAsync(provider.id);
},
[setProxyTargetMutation],
);
// 删除供应商 // 删除供应商
const deleteProvider = useCallback( const deleteProvider = useCallback(
async (id: string) => { async (id: string) => {
@@ -146,14 +136,12 @@ export function useProviderActions(activeApp: AppId) {
addProvider, addProvider,
updateProvider, updateProvider,
switchProvider, switchProvider,
setProxyTarget,
deleteProvider, deleteProvider,
saveUsageScript, saveUsageScript,
isLoading: isLoading:
addProviderMutation.isPending || addProviderMutation.isPending ||
updateProviderMutation.isPending || updateProviderMutation.isPending ||
deleteProviderMutation.isPending || deleteProviderMutation.isPending ||
switchProviderMutation.isPending || switchProviderMutation.isPending,
setProxyTargetMutation.isPending,
}; };
} }
+73
View File
@@ -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<Provider[]> {
return invoke("get_proxy_targets", { appType });
},
// 设置代理目标
async setProxyTarget(
providerId: string,
appType: string,
enabled: boolean,
): Promise<void> {
return invoke("set_proxy_target", { providerId, appType, enabled });
},
// 获取供应商健康状态
async getProviderHealth(
providerId: string,
appType: string,
): Promise<ProviderHealth> {
return invoke("get_provider_health", { providerId, appType });
},
// 重置熔断器
async resetCircuitBreaker(
providerId: string,
appType: string,
): Promise<void> {
return invoke("reset_circuit_breaker", { providerId, appType });
},
// 获取熔断器配置
async getCircuitBreakerConfig(): Promise<CircuitBreakerConfig> {
return invoke("get_circuit_breaker_config");
},
// 更新熔断器配置
async updateCircuitBreakerConfig(
config: CircuitBreakerConfig,
): Promise<void> {
return invoke("update_circuit_breaker_config", { config });
},
// 获取熔断器统计信息
async getCircuitBreakerStats(
providerId: string,
appType: string,
): Promise<CircuitBreakerStats | null> {
return invoke("get_circuit_breaker_stats", { providerId, appType });
},
};
+11
View File
@@ -115,4 +115,15 @@ export const settingsApi = {
async getAutoLaunchStatus(): Promise<boolean> { async getAutoLaunchStatus(): Promise<boolean> {
return await invoke("get_auto_launch_status"); 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");
},
}; };
+6 -20
View File
@@ -19,31 +19,17 @@ export interface SkillRepo {
enabled: boolean; enabled: boolean;
} }
export type AppType = "claude" | "codex" | "gemini";
export const skillsApi = { export const skillsApi = {
async getAll(app: AppType = "claude"): Promise<Skill[]> { async getAll(): Promise<Skill[]> {
if (app === "claude") { return await invoke("get_skills");
return await invoke("get_skills");
}
return await invoke("get_skills_for_app", { app });
}, },
async install(directory: string, app: AppType = "claude"): Promise<boolean> { async install(directory: string): Promise<boolean> {
if (app === "claude") { return await invoke("install_skill", { directory });
return await invoke("install_skill", { directory });
}
return await invoke("install_skill_for_app", { app, directory });
}, },
async uninstall( async uninstall(directory: string): Promise<boolean> {
directory: string, return await invoke("uninstall_skill", { directory });
app: AppType = "claude",
): Promise<boolean> {
if (app === "claude") {
return await invoke("uninstall_skill", { directory });
}
return await invoke("uninstall_skill_for_app", { app, directory });
}, },
async getRepos(): Promise<SkillRepo[]> { async getRepos(): Promise<SkillRepo[]> {
+116
View File
@@ -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 秒刷新一次
});
}
+10
View File
@@ -34,12 +34,22 @@ export interface ProvidersQueryData {
currentProviderId: string; currentProviderId: string;
} }
export interface UseProvidersQueryOptions {
isProxyRunning?: boolean; // 代理服务是否运行中
}
export const useProvidersQuery = ( export const useProvidersQuery = (
appId: AppId, appId: AppId,
options?: UseProvidersQueryOptions,
): UseQueryResult<ProvidersQueryData> => { ): UseQueryResult<ProvidersQueryData> => {
const { isProxyRunning = false } = options || {};
return useQuery({ return useQuery({
queryKey: ["providers", appId], queryKey: ["providers", appId],
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
// 当代理服务运行时,每 10 秒刷新一次供应商列表
// 这样可以自动反映后端熔断器自动禁用代理目标的变更
refetchInterval: isProxyRunning ? 10000 : false,
queryFn: async () => { queryFn: async () => {
let providers: Record<string, Provider> = {}; let providers: Record<string, Provider> = {};
let currentProviderId = ""; let currentProviderId = "";
+33
View File
@@ -48,6 +48,39 @@ export interface ProviderHealth {
updated_at: string; 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 { export interface ProxyUsageRecord {
provider_id: string; provider_id: string;
app_type: string; app_type: string;
+165 -112
View File
@@ -4,119 +4,172 @@ export default {
"./src/index.html", "./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
], ],
darkMode: "selector", darkMode: ["selector", "class"],
theme: { theme: {
extend: { extend: {
colors: { colors: {
// shadcn/ui CSS 变量映射 background: 'hsl(var(--background))',
background: "hsl(var(--background))", foreground: 'hsl(var(--foreground))',
foreground: "hsl(var(--foreground))", card: {
card: { DEFAULT: 'hsl(var(--card))',
DEFAULT: "hsl(var(--card))", foreground: 'hsl(var(--card-foreground))'
foreground: "hsl(var(--card-foreground))", },
}, popover: {
popover: { DEFAULT: 'hsl(var(--popover))',
DEFAULT: "hsl(var(--popover))", foreground: 'hsl(var(--popover-foreground))'
foreground: "hsl(var(--popover-foreground))", },
}, primary: {
primary: { DEFAULT: 'hsl(var(--primary))',
DEFAULT: "hsl(var(--primary))", foreground: 'hsl(var(--primary-foreground))'
foreground: "hsl(var(--primary-foreground))", },
}, secondary: {
secondary: { DEFAULT: 'hsl(var(--secondary))',
DEFAULT: "hsl(var(--secondary))", foreground: 'hsl(var(--secondary-foreground))'
foreground: "hsl(var(--secondary-foreground))", },
}, muted: {
muted: { DEFAULT: 'hsl(var(--muted))',
DEFAULT: "hsl(var(--muted))", foreground: 'hsl(var(--muted-foreground))'
foreground: "hsl(var(--muted-foreground))", },
}, accent: {
accent: { DEFAULT: 'hsl(var(--accent))',
DEFAULT: "hsl(var(--accent))", foreground: 'hsl(var(--accent-foreground))'
foreground: "hsl(var(--accent-foreground))", },
}, destructive: {
destructive: { DEFAULT: 'hsl(var(--destructive))',
DEFAULT: "hsl(var(--destructive))", foreground: 'hsl(var(--destructive-foreground))'
foreground: "hsl(var(--destructive-foreground))", },
}, border: 'hsl(var(--border))',
border: "hsl(var(--border))", input: 'hsl(var(--input))',
input: "hsl(var(--input))", ring: 'hsl(var(--ring))',
ring: "hsl(var(--ring))", blue: {
// macOS 风格系统蓝 '400': '#409CFF',
blue: { '500': '#0A84FF',
400: '#409CFF', '600': '#0060DF'
500: '#0A84FF', },
600: '#0060DF', gray: {
}, '50': '#fafafa',
// 自定义灰色系列(对齐 macOS 深色 System Gray '100': '#f4f4f5',
gray: { '200': '#e4e4e7',
50: '#fafafa', // bg-primary '300': '#d4d4d8',
100: '#f4f4f5', // bg-tertiary '400': '#a1a1aa',
200: '#e4e4e7', // border '500': '#71717a',
300: '#d4d4d8', // border-hover '600': '#636366',
400: '#a1a1aa', // text-tertiary '700': '#48484A',
500: '#71717a', // text-secondary '800': '#3A3A3C',
600: '#636366', // text-secondary-dark / systemGray2 '900': '#2C2C2E',
700: '#48484A', // bg-tertiary-dark / separators '950': '#1C1C1E'
800: '#3A3A3C', // bg-secondary-dark },
900: '#2C2C2E', // header / modal bg green: {
950: '#1C1C1E', // app main bg '100': '#d1fae5',
}, '500': '#10b981'
// 状态颜色 },
green: { red: {
500: '#10b981', '100': '#fee2e2',
100: '#d1fae5', '500': '#ef4444'
}, },
red: { amber: {
500: '#ef4444', '100': '#fef3c7',
100: '#fee2e2', '500': '#f59e0b'
}, }
amber: { },
500: '#f59e0b', boxShadow: {
100: '#fef3c7', 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)'
boxShadow: { },
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)', borderRadius: {
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', sm: '0.375rem',
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', md: '0.5rem',
}, lg: '0.75rem',
borderRadius: { xl: '0.875rem'
'sm': '0.375rem', },
'md': '0.5rem', fontFamily: {
'lg': '0.75rem', // 使用与之前版本保持一致的系统字体栈
'xl': '0.875rem', sans: [
}, '-apple-system',
fontFamily: { 'BlinkMacSystemFont',
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'], '"Segoe UI"',
mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'], 'Roboto',
}, '"Helvetica Neue"',
animation: { 'Arial',
'fade-in': 'fadeIn 0.5s ease-out', 'sans-serif',
'slide-up': 'slideUp 0.5s ease-out', ],
'slide-down': 'slideDown 0.3s ease-out', mono: [
'slide-in-right': 'slideInRight 0.3s ease-out', 'ui-monospace',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'SFMono-Regular',
}, '"SF Mono"',
keyframes: { 'Consolas',
fadeIn: { '"Liberation Mono"',
'0%': { opacity: '0' }, 'Menlo',
'100%': { opacity: '1' }, 'monospace',
}, ],
slideUp: { },
'0%': { transform: 'translateY(20px)', opacity: '0' }, animation: {
'100%': { transform: 'translateY(0)', opacity: '1' }, 'fade-in': 'fadeIn 0.5s ease-out',
}, 'slide-up': 'slideUp 0.5s ease-out',
slideDown: { 'slide-down': 'slideDown 0.3s ease-out',
'0%': { transform: 'translateY(-100%)', opacity: '0' }, 'slide-in-right': 'slideInRight 0.3s ease-out',
'100%': { transform: 'translateY(0)', opacity: '1' }, 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
}, 'accordion-down': 'accordion-down 0.2s ease-out',
slideInRight: { 'accordion-up': 'accordion-up 0.2s ease-out'
'0%': { transform: 'translateX(100%)', opacity: '0' }, },
'100%': { transform: 'translateX(0)', opacity: '1' }, 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: [], plugins: [],
} }
-3
View File
@@ -125,7 +125,6 @@ describe("ProviderList Component", () => {
onDelete={vi.fn()} onDelete={vi.fn()}
onDuplicate={vi.fn()} onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()} onOpenWebsite={vi.fn()}
onSetProxyTarget={vi.fn()}
isLoading isLoading
/>, />,
); );
@@ -154,7 +153,6 @@ describe("ProviderList Component", () => {
onDelete={vi.fn()} onDelete={vi.fn()}
onDuplicate={vi.fn()} onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()} onOpenWebsite={vi.fn()}
onSetProxyTarget={vi.fn()}
onCreate={handleCreate} onCreate={handleCreate}
/>, />,
); );
@@ -195,7 +193,6 @@ describe("ProviderList Component", () => {
onDuplicate={handleDuplicate} onDuplicate={handleDuplicate}
onConfigureUsage={handleUsage} onConfigureUsage={handleUsage}
onOpenWebsite={handleOpenWebsite} onOpenWebsite={handleOpenWebsite}
onSetProxyTarget={vi.fn()}
/>, />,
); );