Compare commits

..

19 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 1fb2d5ed44 feat(skill): add multi-app skill support for Claude/Codex (#365)
* feat(skill): add multi-app skill support for Claude/Codex/Gemini

- Add app-specific skill management with AppType prefix in skill keys
- Implement per-app skill tracking in database schema
- Add get_skills_for_app command to retrieve skills by application
- Update SkillsPage to support app-specific skill loading with initialApp prop
- Parse app parameter and validate against supported app types
- Maintain backward compatibility with default claude app

* fix(usage): reorder cache columns and prevent header text wrapping

- Swap cache read and cache write columns order
- Add whitespace-nowrap to all table headers to prevent text wrapping
- Improves table readability and layout consistency
2025-12-08 20:54:17 +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
YoVinchen 622a24ded4 fix(skill): use directory basename for skill installation path (#358)
Extract last segment from skill directory path to prevent nested directory
issues during install/uninstall operations. For example, "skills/codex" now
correctly installs to "codex" instead of creating nested "skills/codex" path.
2025-12-05 14:57:06 +08:00
Jason 6713368657 fix(windows): use system titlebar to prevent black screen on startup
The `titleBarStyle: "Overlay"` setting in tauri.conf.json causes black
screen and crash on Windows due to WebView2 compatibility issues.

Add platform-specific config `tauri.windows.conf.json` to override
titleBarStyle to "Visible" on Windows while keeping the overlay style
on macOS for the immersive UI experience.

Fixes black screen issue on Windows for v3.8.x releases.
2025-12-05 11:44:54 +08:00
YoVinchen b1103c8a59 Feat/proxy server (#355)
* feat(proxy): implement local HTTP proxy server with multi-provider failover

Add a complete HTTP proxy server implementation built on Axum framework,
enabling local API request forwarding with automatic provider failover
and load balancing capabilities.

Backend Implementation (Rust):
- Add proxy server module with 7 core components:
  * server.rs: Axum HTTP server lifecycle management (start/stop/status)
  * router.rs: API routing configuration for Claude/OpenAI/Gemini endpoints
  * handlers.rs: Request/response handling and transformation
  * forwarder.rs: Upstream forwarding logic with retry mechanism (652 lines)
  * error.rs: Comprehensive error handling and HTTP status mapping
  * types.rs: Shared types (ProxyConfig, ProxyStatus, ProxyServerInfo)
  * health.rs: Provider health check infrastructure

Service Layer:
- Add ProxyService (services/proxy.rs, 157 lines):
  * Manage proxy server lifecycle
  * Handle configuration updates
  * Track runtime status and metrics

Database Layer:
- Add proxy configuration DAO (dao/proxy.rs, 242 lines):
  * Persist proxy settings (listen address, port, timeout)
  * Store provider priority and availability flags
- Update schema with proxy_config table (schema.rs):
  * Support runtime configuration persistence

Tauri Commands:
- Add 6 command endpoints (commands/proxy.rs):
  * start_proxy_server: Launch proxy server
  * stop_proxy_server: Gracefully shutdown server
  * get_proxy_status: Query runtime status
  * get_proxy_config: Retrieve current configuration
  * update_proxy_config: Modify settings without restart
  * is_proxy_running: Check server state

Frontend Implementation (React + TypeScript):
- Add ProxyPanel component (222 lines):
  * Real-time server status display
  * Start/stop controls
  * Provider availability monitoring
- Add ProxySettingsDialog component (420 lines):
  * Configuration editor (address, port, timeout)
  * Provider priority management
  * Settings validation
- Add React hooks:
  * useProxyConfig: Manage proxy configuration state
  * useProxyStatus: Poll and display server status
- Add TypeScript types (types/proxy.ts):
  * Define ProxyConfig, ProxyStatus interfaces

Provider Integration:
- Extend Provider model with availability field (providers.rs):
  * Track provider health for failover logic
- Update ProviderCard UI to display proxy status
- Integrate proxy controls in Settings page

Dependencies:
- Add Axum 0.7 (async web framework)
- Add Tower 0.4 (middleware and service abstractions)
- Add Tower-HTTP (CORS layer)
- Add Tokio sync primitives (oneshot, RwLock)

Technical Details:
- Graceful shutdown via oneshot channel
- Shared state with Arc<RwLock<T>> for thread-safe config updates
- CORS enabled for cross-origin frontend access
- Request/response streaming support
- Automatic retry with exponential backoff (forwarder)
- API key extraction from multiple config formats (Claude/Codex/Gemini)

File Statistics:
- 41 files changed
- 3491 insertions(+), 41 deletions(-)
- Core modules: 1393 lines (server + forwarder + handlers)
- Frontend UI: 642 lines (ProxyPanel + ProxySettingsDialog)
- Database/DAO: 326 lines

This implementation provides the foundation for advanced features like:
- Multi-provider load balancing
- Automatic failover on provider errors
- Request logging and analytics
- Usage tracking and cost monitoring

* fix(proxy): resolve UI/UX issues and database constraint error

Simplify proxy control interface and fix database persistence issues:

Backend Fixes:
- Fix NOT NULL constraint error in proxy_config.created_at field
  * Use COALESCE to preserve created_at on updates
  * Ensure proper INSERT OR REPLACE behavior
- Remove redundant enabled field validation on startup
  * Auto-enable when user clicks start button
  * Persist enabled state after successful start
- Preserve enabled state during config updates
  * Prevent accidental service shutdown on config save

Frontend Improvements:
- Remove duplicate proxy enable switch from settings dialog
  * Keep only runtime toggle in ProxyPanel
  * Simplify user experience with single control point
- Hide proxy target button when proxy service is stopped
  * Add isProxyRunning prop to ProviderCard
  * Conditionally render proxy controls based on service status
- Update form schema to omit enabled field
  * Managed automatically by backend

Files: 5 changed, 81 insertions(+), 94 deletions(-)

* fix(proxy): improve URL building and Gemini request handling

- Refactor URL construction with version path deduplication (/v1, /v1beta)
- Preserve query parameters for Gemini API requests
- Support GOOGLE_GEMINI_API_KEY field name (with fallback)
- Change default proxy port from 5000 to 15721
- Fix test: use Option type for is_proxy_target field

* refactor(proxy): remove unused request handlers and routes

- Remove unused GET/DELETE request forwarding methods
- Remove count_tokens, get/delete response handlers
- Simplify router by removing unused endpoints
- Keep only essential routes: /v1/messages, /v1/responses, /v1beta/*

* Merge branch 'main' into feat/proxy-server

* fix(proxy): resolve clippy warnings for dead code and uninlined format args

- Add #[allow(dead_code)] to unused ProviderUnhealthy variant
- Inline format string arguments in handlers.rs and codex.rs log macros
- Refactor error response handling to properly pass through upstream errors
- Add URL deduplication logic for /v1/v1 paths in CodexAdapter

* feat(proxy): implement provider adapter pattern with OpenRouter support

This major refactoring introduces a modular provider adapter architecture
to support format transformation between different AI API formats.

New features:
- Add ProviderAdapter trait for unified provider abstraction
- Implement Claude, Codex, and Gemini adapters with specific logic
- Add Anthropic ↔ OpenAI format transformation for OpenRouter compatibility
- Support model mapping from provider configuration (ANTHROPIC_MODEL, etc.)
- Add OpenRouter preset to Claude provider presets

Refactoring:
- Extract authentication logic into auth.rs with AuthInfo and AuthStrategy
- Move URL building and request transformation to individual adapters
- Simplify ProviderRouter to only use proxy target providers
- Refactor RequestForwarder to use adapter-based request/response handling
- Use whitelist mode for header forwarding (only pass necessary headers)

Architecture:
- providers/adapter.rs: ProviderAdapter trait definition
- providers/auth.rs: AuthInfo, AuthStrategy types
- providers/claude.rs: Claude adapter with OpenRouter detection
- providers/codex.rs: Codex (OpenAI) adapter
- providers/gemini.rs: Gemini (Google) adapter
- providers/models/: Anthropic and OpenAI API data models
- providers/transform.rs: Bidirectional format transformation

* feat(proxy): add streaming SSE transform and thinking parameter support

New features:
- Add OpenAI → Anthropic SSE streaming response transformation
- Support thinking parameter detection for reasoning model selection
- Add ANTHROPIC_REASONING_MODEL config option for extended thinking

Changes:
- streaming.rs: Implement SSE event parsing and Anthropic format conversion
- transform.rs: Add thinking detection logic and reasoning model mapping
- handlers.rs: Integrate streaming transform for OpenRouter compatibility
- Cargo.toml: Add async-stream and bytes dependencies

* feat(db): add usage tracking schema and types

Add database tables for proxy request logs and model pricing.
Extend Provider and error types to support usage statistics.

* feat(proxy): implement usage tracking subsystem

Add request logger with automatic cost calculation.
Implement token parser for Claude/OpenAI/Gemini responses.
Add cost calculator based on model pricing configuration.

* feat(proxy): integrate usage logging into request handlers

Add usage logging to forwarder and streaming handlers.
Track token usage and costs for each proxy request.

* feat(commands): add usage statistics Tauri commands

Register usage commands for summary, trends, logs, and pricing.
Expose usage stats service through Tauri command layer.

* feat(api): add frontend usage API and query hooks

Add TypeScript types for usage statistics.
Implement usage API with Tauri invoke calls.
Add TanStack Query hooks for usage data fetching.

* feat(ui): add usage dashboard components

Add UsageDashboard with summary cards, trend chart, and data tables.
Implement model pricing configuration panel.
Add request log viewer with filtering and detail panel.

* fix(ui): integrate usage dashboard and fix type errors

Add usage dashboard tab to settings page.
Fix UsageScriptModal TypeScript type annotations.

* deps: add recharts for charts and rust_decimal/uuid for usage tracking

- recharts: Chart visualization for usage trends
- rust_decimal: Precise cost calculations
- uuid: Request ID generation

* feat(proxy): add ProviderType enum for fine-grained provider detection

Introduce ProviderType enum to distinguish between different provider
implementations (Claude, ClaudeAuth, Codex, Gemini, GeminiCli, OpenRouter).
This enables proper authentication handling and request transformation
based on the actual provider type rather than just AppType.

- Add ProviderType enum with detection logic from config
- Enhance Claude adapter with OpenRouter detection
- Enhance Gemini adapter with CLI mode detection
- Add helper methods for provider type inference

* feat(database): extend schema with streaming and timing fields

Add new columns to proxy_request_logs table for enhanced usage tracking:
- first_token_ms and duration_ms for performance metrics
- provider_type and is_streaming for request classification
- cost_multiplier for flexible pricing

Update model pricing with accurate rates for Claude/GPT/Gemini models.
Add ensure_model_pricing_seeded() call on database initialization.
Add test for model pricing auto-seeding verification.

* feat(proxy/usage): enhance token parser and logger for multi-format support

Parser enhancements:
- Add OpenAI Chat Completions format parsing (prompt_tokens/completion_tokens)
- Add model field to TokenUsage for actual model name extraction
- Add from_codex_response_adjusted() for proper cache token handling
- Add debug logging for better stream event tracing

Logger enhancements:
- Add first_token_ms, provider_type, is_streaming, cost_multiplier fields
- Extend RequestLog struct with full metadata tracking
- Update log_with_calculation() signature for new fields

Calculator: Update tests with model field in TokenUsage.

* feat(proxy): enhance proxy server with session tracking and OpenAI route

Error handling:
- Add StreamIdleTimeout and AuthError variants for better error classification

Module exports:
- Export ResponseType, StreamHandler, NonStreamHandler from response_handler
- Export ProxySession, ClientFormat from session module

Server routing:
- Add /v1/chat/completions route for OpenAI Chat Completions API

Handlers:
- Add log_usage_with_session() for enhanced usage tracking with session context
- Add first_token_ms timing measurement for streaming responses
- Use SseUsageCollector with start_time for accurate latency calculation
- Track is_streaming flag in usage logs

* feat(services): add pagination and enhanced filtering for request logs

Usage stats service:
- Change get_request_logs() from limit/offset to page/page_size pagination
- Return PaginatedLogs with total count, page, and page_size
- Add appType and providerName filters with LIKE search
- Add is_streaming, first_token_ms, duration_ms to RequestLogDetail
- Join with providers table for provider name lookup

Commands:
- Update get_request_logs command signature for pagination params

Module exports:
- Export PaginatedLogs struct

* feat(frontend): update usage types and API for pagination support

Types (usage.ts):
- Add isStreaming, firstTokenMs, durationMs to RequestLog
- Add PaginatedLogs interface with data, total, page, pageSize
- Change LogFilters: providerId -> appType + providerName

API (usage.ts):
- Change getRequestLogs params from limit/offset to page/pageSize
- Return PaginatedLogs instead of RequestLog[]
- Pass filters object directly to backend

Query (usage.ts):
- Update usageKeys.logs key generation for pagination
- Update useRequestLogs hook signature

* refactor(ui): enhance RequestLogTable with filtering and pagination

UI improvements:
- Add filter bar with app type, provider name, model, status selectors
- Add date range picker (startDate/endDate)
- Add search/reset/refresh buttons

Pagination:
- Implement proper page-based pagination with page info display
- Show total count and current page range
- Add prev/next navigation buttons

Features:
- Default to last 24 hours filter
- Streamlined table columns layout
- Query invalidation on refresh

* style(config): format mcpPresets code style

Apply consistent formatting to createNpxCommand function and
sequential-thinking server configuration.

* fix(ui): update SettingsPage tab styles for improved appearance (#342)

* feat(model-test): add provider model availability testing

Implement standalone model testing feature to verify provider API connectivity:
- Add ModelTestService for Claude/Codex/Gemini endpoint testing
- Create model_test_logs table for test result persistence
- Add test button to ProviderCard with loading state
- Include ModelTestConfigPanel for customizing test parameters

* fix(proxy): resolve token parsing for OpenRouter streaming responses

Problem:
- OpenRouter and similar third-party services return streaming responses
  where input_tokens appear in message_delta instead of message_start
- The previous implementation only extracted input_tokens from message_start,
  causing input_tokens to be recorded as 0 for these providers

Changes:
- streaming.rs: Add prompt_tokens field to Usage struct and include
  input_tokens in the transformed message_delta event when converting
  OpenAI format to Anthropic format
- parser.rs: Update from_claude_stream_events() to handle input_tokens
  from both message_start (native Claude API) and message_delta (OpenRouter)
  - Use if-let pattern instead of direct unwrap for safer parsing
  - Only update input_tokens from message_delta if not already set
- logger.rs: Adjust test parameters to match updated function signature

Tests:
- Add test_openrouter_stream_parsing() for OpenRouter format validation
- Add test_native_claude_stream_parsing() for native Claude API validation

* fix(pricing): standardize model ID format for pricing lookup

Normalize model IDs by removing vendor prefixes and converting dots to hyphens to ensure consistent pricing lookups across different API response formats.

Changes:
- Update seed data to use hyphen format (e.g., gpt-5-1, gemini-2-5-pro)
- Add normalize_model_id() function to strip vendor prefixes (anthropic/, openai/)
- Convert dots to hyphens in model IDs (claude-haiku-4.5 → claude-haiku-4-5)
- Try both original and normalized IDs for exact matching
- Use normalized ID for suffix-based fallback matching
- Add comprehensive test cases for prefix and dot handling
- Add warning log when no pricing found

This ensures pricing lookups work correctly for:
- Models with vendor prefixes: anthropic/claude-haiku-4.5
- Models with dots in version: claude-sonnet-4.5
- Models with date suffixes: claude-haiku-4-5-20240229

* style(rust): apply clippy formatting suggestions

Apply automatic clippy fixes for uninlined_format_args warnings across Rust codebase. Replace format string placeholders with inline variable syntax for improved readability.

Changes:
- Convert format!("{}", var) to format!("{var}")
- Apply to model_test.rs, parser.rs, and usage_stats.rs
- Fix line length issues by breaking long function calls
- Improve code formatting consistency

All changes are automatic formatting with no functional impact.

* fix(ui): restore card borders in usage statistics panels

Restore proper card styling for ModelTestConfigPanel and PricingConfigPanel by adding back border and rounded-lg classes. The transparent background styling was causing visual inconsistency.

Changes:
- Replace border-none bg-transparent shadow-none with border rounded-lg
- Apply to both loading and error states for consistency
- Format TypeScript code for better readability
- Break long function signatures across multiple lines

This ensures the usage statistics panels have consistent visual appearance with proper borders and rounded corners.

* feat(pricing): add GPT-5 Codex model pricing presets

Add pricing configuration for GPT-5 Codex variants to support cost tracking for Codex-specific models.

Changes:
- Add gpt-5-codex model with standard GPT-5 pricing
- Add gpt-5-1-codex model with standard GPT-5.1 pricing
- Input: $1.25/M tokens, Output: $10/M tokens
- Cache read: $0.125/M tokens, Cache creation: $0

This ensures accurate cost calculation for Codex API requests using GPT-5 Codex models.
2025-12-05 11:26:41 +08:00
50 changed files with 3930 additions and 1059 deletions
+3
View File
@@ -50,6 +50,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -67,7 +68,9 @@
"@tauri-apps/plugin-updater": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
+121
View File
@@ -44,6 +44,9 @@ importers:
'@lobehub/icons-static-svg':
specifier: ^1.73.0
version: 1.73.0
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -95,9 +98,15 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
codemirror:
specifier: ^6.0.2
version: 6.0.2
framer-motion:
specifier: ^12.23.25
version: 12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
i18next:
specifier: ^25.5.2
version: 25.5.2(typescript@5.9.2)
@@ -652,6 +661,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accordion@1.2.12':
resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -678,6 +700,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@@ -1527,6 +1562,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
@@ -1752,6 +1793,20 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.23.25:
resolution: {integrity: sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2035,6 +2090,12 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3183,6 +3244,23 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3208,6 +3286,22 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
@@ -3988,6 +4082,18 @@ snapshots:
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.18.7
@@ -4202,6 +4308,15 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.23.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.23.23
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fsevents@2.3.3:
optional: true
@@ -4444,6 +4559,12 @@ snapshots:
min-indent@1.0.1: {}
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
ms@2.1.3: {}
msw@2.11.6(@types/node@20.19.9)(typescript@5.9.2):
+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> {
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 接口
use crate::provider::Provider;
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
/// 启动代理服务器
@@ -45,3 +47,115 @@ pub async fn update_proxy_config(
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
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)
}
+110
View File
@@ -357,6 +357,116 @@ impl Database {
Ok(())
}
/// 设置单个供应商的代理目标状态(支持多个代理目标)
pub async fn set_proxy_target(
&self,
provider_id: &str,
app_type: &str,
enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"UPDATE providers SET is_proxy_target = ?1
WHERE id = ?2 AND app_type = ?3",
params![if enabled { 1 } else { 0 }, provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 获取指定应用类型的所有代理目标供应商(按 sort_index 排序)
pub async fn get_proxy_targets(
&self,
app_type: &str,
) -> Result<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> {
let conn = lock_conn!(self.conn);
+77
View File
@@ -165,6 +165,25 @@ impl Database {
Ok(())
}
/// 重置Provider健康状态
pub async fn reset_provider_health(
&self,
provider_id: &str,
app_type: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
log::debug!("Reset health status for provider {provider_id} (app: {app_type})");
Ok(())
}
// ==================== Proxy Usage (可选) ====================
/// 记录代理使用统计
@@ -241,4 +260,62 @@ impl Database {
Ok(records)
}
// ==================== Circuit Breaker Config ====================
/// 获取熔断器配置
pub async fn get_circuit_breaker_config(
&self,
) -> Result<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(())
}
}
+22
View File
@@ -336,6 +336,28 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 15. Circuit Breaker Config 表 (熔断器配置)
conn.execute(
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
failure_threshold INTEGER NOT NULL DEFAULT 5,
success_threshold INTEGER NOT NULL DEFAULT 2,
timeout_seconds INTEGER NOT NULL DEFAULT 60,
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
min_requests INTEGER NOT NULL DEFAULT 10,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 插入默认熔断器配置
conn.execute(
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
+9
View File
@@ -650,6 +650,14 @@ pub fn run() {
commands::get_proxy_config,
commands::update_proxy_config,
commands::is_proxy_running,
// Proxy failover commands
commands::get_proxy_targets,
commands::set_proxy_target,
commands::get_provider_health,
commands::reset_circuit_breaker,
commands::get_circuit_breaker_config,
commands::update_circuit_breaker_config,
commands::get_circuit_breaker_stats,
// Usage statistics
commands::get_usage_summary,
commands::get_usage_trends,
@@ -668,6 +676,7 @@ pub fn run() {
commands::save_model_test_config,
commands::get_model_test_logs,
commands::cleanup_model_test_logs,
commands::get_tool_versions,
]);
let app = builder
+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::{
error::*,
provider_router::ProviderRouter as NewProviderRouter,
providers::{get_adapter, ProviderAdapter},
router::ProviderRouter,
types::ProxyStatus,
ProxyError,
};
@@ -18,9 +18,11 @@ use tokio::sync::RwLock;
pub struct RequestForwarder {
client: Client,
router: ProviderRouter,
router: Arc<NewProviderRouter>,
#[allow(dead_code)]
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
}
impl RequestForwarder {
@@ -29,6 +31,7 @@ impl RequestForwarder {
timeout_secs: u64,
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
) -> Self {
let mut client_builder = Client::builder();
if timeout_secs > 0 {
@@ -41,13 +44,14 @@ impl RequestForwarder {
Self {
client,
router: ProviderRouter::new(db),
router: Arc::new(NewProviderRouter::new(db)),
max_retries,
status,
current_providers,
}
}
/// 转发请求(带重试和故障转移)
/// 转发请求(带故障转移)
pub async fn forward_with_retry(
&self,
app_type: &AppType,
@@ -55,21 +59,39 @@ impl RequestForwarder {
body: Value,
headers: axum::http::HeaderMap,
) -> Result<Response, ProxyError> {
let mut failed_ids = Vec::new();
let mut failover_happened = false;
// 获取适配器
let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str();
for attempt in 0..self.max_retries {
// 选择Provider
let provider = self.router.select_provider(app_type, &failed_ids).await?;
// 使用新的 ProviderRouter 选择所有可用供应商
let providers = self
.router
.select_providers(app_type_str)
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
log::debug!(
"尝试 {} - 使用Provider: {} ({})",
if providers.is_empty() {
return Err(ProxyError::NoAvailableProvider);
}
log::info!(
"[{}] 故障转移链: {} 个可用供应商",
app_type_str,
providers.len()
);
let mut last_error = None;
let mut failover_happened = false;
// 依次尝试每个供应商
for (attempt, provider) in providers.iter().enumerate() {
log::info!(
"[{}] 尝试 {}/{} - 使用Provider: {} (sort_index: {})",
app_type_str,
attempt + 1,
providers.len(),
provider.name,
provider.id
provider.sort_index.unwrap_or(999999)
);
// 更新状态中的当前Provider信息
@@ -88,16 +110,29 @@ impl RequestForwarder {
// 转发请求
match self
.forward(&provider, endpoint, &body, &headers, adapter.as_ref())
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
let _latency = start.elapsed().as_millis() as u64;
let latency = start.elapsed().as_millis() as u64;
// 成功:更新健康状态
self.router
.update_health(&provider, app_type, true, None)
.await;
// 成功:记录成功并更新熔断器
if let Err(e) = self
.router
.record_result(&provider.id, app_type_str, true, None)
.await
{
log::warn!("Failed to record success: {e}");
}
// 更新当前应用类型使用的 provider
{
let mut current_providers = self.current_providers.write().await;
current_providers.insert(
app_type_str.to_string(),
(provider.id.clone(), provider.name.clone()),
);
}
// 更新成功统计
{
@@ -106,6 +141,12 @@ impl RequestForwarder {
status.last_error = None;
if failover_happened {
status.failover_count += 1;
log::info!(
"[{}] 故障转移成功!切换到 Provider: {} (耗时: {}ms)",
app_type_str,
provider.name,
latency
);
}
// 重新计算成功率
if status.total_requests > 0 {
@@ -115,23 +156,33 @@ impl RequestForwarder {
}
}
log::info!(
"[{}] 请求成功 - Provider: {} - {}ms",
app_type_str,
provider.name,
latency
);
return Ok(response);
}
Err(e) => {
let latency = start.elapsed().as_millis() as u64;
// 失败:分类错误
// 失败:记录失败并更新熔断器
if let Err(record_err) = self
.router
.record_result(&provider.id, app_type_str, false, Some(e.to_string()))
.await
{
log::warn!("Failed to record failure: {record_err}");
}
// 分类错误
let category = self.categorize_proxy_error(&e);
match category {
ErrorCategory::Retryable => {
// 可重试:更新健康状态,添加到失败列表
self.router
.update_health(&provider, app_type, false, Some(e.to_string()))
.await;
failed_ids.push(provider.id.clone());
// 更新错误信息
// 可重试:更新错误信息,继续尝试下一个供应商
{
let mut status = self.status.write().await;
status.last_error =
@@ -139,15 +190,19 @@ impl RequestForwarder {
}
log::warn!(
"请求失败(可重试): Provider {} - {} - {}ms",
"[{}] Provider {} 失败(可重试): {} - {}ms",
app_type_str,
provider.name,
e,
latency
);
last_error = Some(e);
// 继续尝试下一个供应商
continue;
}
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
// 不可重试:更新失败统计并返回
// 不可重试:直接返回错误
{
let mut status = self.status.write().await;
status.failed_requests += 1;
@@ -158,7 +213,12 @@ impl RequestForwarder {
* 100.0;
}
}
log::error!("请求失败(不可重试): {e}");
log::error!(
"[{}] Provider {} 失败(不可重试): {}",
app_type_str,
provider.name,
e
);
return Err(e);
}
}
@@ -166,18 +226,24 @@ impl RequestForwarder {
}
}
// 所有重试都失败
// 所有供应商都失败
{
let mut status = self.status.write().await;
status.failed_requests += 1;
status.last_error = Some("已达到最大重试次数".to_string());
status.last_error = Some("所有供应商都失败".to_string());
if status.total_requests > 0 {
status.success_rate =
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
}
}
Err(ProxyError::MaxRetriesExceeded)
log::error!(
"[{}] 所有 {} 个供应商都失败了",
app_type_str,
providers.len()
);
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
}
/// 转发单个请求(使用适配器)
+4
View File
@@ -322,6 +322,7 @@ pub async fn handle_messages(
config.request_timeout,
config.max_retries,
state.status.clone(),
state.current_providers.clone(),
);
let response = forwarder
@@ -641,6 +642,7 @@ pub async fn handle_gemini(
config.request_timeout,
config.max_retries,
state.status.clone(),
state.current_providers.clone(),
);
// 提取完整的路径和查询参数
@@ -806,6 +808,7 @@ pub async fn handle_responses(
config.request_timeout,
config.max_retries,
state.status.clone(),
state.current_providers.clone(),
);
let response = forwarder
@@ -985,6 +988,7 @@ pub async fn handle_chat_completions(
config.request_timeout,
config.max_retries,
state.status.clone(),
state.current_providers.clone(),
);
let response = forwarder
+8
View File
@@ -2,10 +2,12 @@
//!
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
pub mod circuit_breaker;
pub mod error;
mod forwarder;
mod handlers;
mod health;
pub mod provider_router;
pub mod providers;
pub mod response_handler;
mod router;
@@ -16,8 +18,14 @@ pub mod usage;
// 公开导出给外部使用(commands, services等模块需要)
#[allow(unused_imports)]
pub use circuit_breaker::{
CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState,
};
#[allow(unused_imports)]
pub use error::ProxyError;
#[allow(unused_imports)]
pub use provider_router::ProviderRouter;
#[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)]
pub use session::{ClientFormat, ProxySession};
+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健康状态(保留接口但不影响选择)
#[allow(dead_code)]
pub async fn update_health(
&self,
_provider: &Provider,
+13 -11
View File
@@ -20,6 +20,8 @@ pub struct ProxyState {
pub config: Arc<RwLock<ProxyConfig>>,
pub status: Arc<RwLock<ProxyStatus>>,
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服务器
@@ -36,6 +38,7 @@ impl ProxyServer {
config: Arc::new(RwLock::new(config.clone())),
status: Arc::new(RwLock::new(ProxyStatus::default())),
start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
};
Self {
@@ -121,17 +124,16 @@ impl ProxyServer {
status.uptime_seconds = start.elapsed().as_secs();
}
// 获取所有活跃的代理目标
if let Ok(targets) = self.state.db.get_all_proxy_targets() {
status.active_targets = targets
.into_iter()
.map(|(app_type, name, id)| ActiveTarget {
app_type,
provider_name: name,
provider_id: id,
})
.collect();
}
// 从 current_providers HashMap 获取每个应用类型当前正在使用的 provider
let current_providers = self.state.current_providers.read().await;
status.active_targets = current_providers
.iter()
.map(|(app_type, (provider_id, provider_name))| ActiveTarget {
app_type: app_type.clone(),
provider_id: provider_id.clone(),
provider_name: provider_name.clone(),
})
.collect();
status
}
+27 -3
View File
@@ -316,9 +316,20 @@ impl SkillService {
let directory = &local_skill.directory;
// 更新已安装状态(匹配远程技能)
// 使用目录最后一段进行比较,因为安装时只使用最后一段作为目录名
let mut found = false;
let local_install_name = Path::new(directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory.clone());
for skill in skills.iter_mut() {
if skill.directory.eq_ignore_ascii_case(directory) {
let remote_install_name = Path::new(&skill.directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| skill.directory.clone());
if remote_install_name.eq_ignore_ascii_case(&local_install_name) {
skill.installed = true;
found = true;
break;
@@ -517,7 +528,14 @@ impl SkillService {
/// 安装技能(仅负责下载和文件操作,状态更新由上层负责)
pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> {
let dest = self.install_dir.join(&directory);
// 使用技能目录的最后一段作为安装目录名,避免嵌套路径问题
// 例如: "skills/codex" -> "codex"
let install_name = Path::new(&directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory.clone());
let dest = self.install_dir.join(&install_name);
// 若目标目录已存在,则视为已安装,避免重复下载
if dest.exists() {
@@ -589,7 +607,13 @@ impl SkillService {
/// 卸载技能(仅负责文件操作,状态更新由上层负责)
pub fn uninstall_skill(&self, directory: String) -> Result<()> {
let dest = self.install_dir.join(&directory);
// 使用技能目录的最后一段作为安装目录名,与 install_skill 保持一致
let install_name = Path::new(&directory)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| directory.clone());
let dest = self.install_dir.join(&install_name);
if dest.exists() {
fs::remove_dir_all(&dest)?;
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"label": "main",
"titleBarStyle": "Visible"
}
]
}
}
+25 -22
View File
@@ -23,8 +23,8 @@ import {
} from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { extractErrorMessage } from "@/utils/errorUtils";
import { cn } from "@/lib/utils";
import { AppSwitcher } from "@/components/AppSwitcher";
import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
@@ -62,10 +62,17 @@ function App() {
const addActionButtonClass =
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
// 获取代理服务状态
const { isRunning: isProxyRunning } = useProxyStatus();
// 获取供应商列表,当代理服务运行时自动刷新
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
isProxyRunning,
});
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
// Skills 功能仅支持 Claude 和 Codex
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -74,7 +81,6 @@ function App() {
switchProvider,
deleteProvider,
saveUsageScript,
setProxyTarget,
} = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件
@@ -291,6 +297,7 @@ function App() {
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp}
/>
);
case "mcp":
@@ -313,8 +320,8 @@ function App() {
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
onSwitch={switchProvider}
onSetProxyTarget={setProxyTarget}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
@@ -368,7 +375,7 @@ function App() {
)}
<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
style={{ WebkitAppRegion: "drag" } as any}
>
@@ -477,22 +484,18 @@ function App() {
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className={cn(
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
"transition-all duration-200 ease-in-out overflow-hidden",
isClaudeApp
? "opacity-100 w-8 scale-100 px-2"
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
)}
title={t("skills.manage")}
>
<Wrench className="h-4 w-4 flex-shrink-0" />
</Button>
<div className="bg-muted p-1 rounded-xl flex items-center gap-1">
{hasSkillsSupport && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skills")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
{/* {isClaudeApp && (
<Button
+12 -12
View File
@@ -24,14 +24,14 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
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
type="button"
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 ${
activeApp === "claude"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "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"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
@@ -41,7 +41,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={
activeApp === "claude"
? "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>
@@ -50,10 +50,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button
type="button"
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"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "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"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
@@ -63,7 +63,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={
activeApp === "codex"
? "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>
@@ -72,10 +72,10 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
<button
type="button"
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"
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100"
: "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"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
<ProviderIcon
@@ -85,7 +85,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
className={
activeApp === "gemini"
? "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>
+42 -16
View File
@@ -33,13 +33,23 @@ export function EditProviderDialog({
unknown
> | null>(null);
// 使用 ref 标记是否已经加载过,防止重复读取覆盖用户编辑
const [hasLoadedLive, setHasLoadedLive] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!open || !provider) {
setLiveSettings(null);
setHasLoadedLive(false);
return;
}
// 关键修复:只在首次打开时加载一次
if (hasLoadedLive) {
return;
}
try {
const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) {
@@ -49,13 +59,20 @@ export function EditProviderDialog({
)) as Record<string, unknown>;
if (!cancelled && live && typeof live === "object") {
setLiveSettings(live);
setHasLoadedLive(true);
}
} catch {
// 读取实时配置失败则回退到 SSOT(不打断编辑流程)
if (!cancelled) setLiveSettings(null);
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
}
} else {
if (!cancelled) setLiveSettings(null);
if (!cancelled) {
setLiveSettings(null);
setHasLoadedLive(true);
}
}
} finally {
// no-op
@@ -65,14 +82,33 @@ export function EditProviderDialog({
return () => {
cancelled = true;
};
}, [open, provider, appId]);
}, [open, provider?.id, appId, hasLoadedLive]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
string,
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(
async (values: ProviderFormValues) => {
@@ -104,7 +140,7 @@ export function EditProviderDialog({
[onSubmit, onOpenChange, provider],
);
if (!provider) {
if (!provider || !initialData) {
return null;
}
@@ -130,17 +166,7 @@ export function EditProviderDialog({
submitLabel={t("common.save")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
initialData={{
name: provider.name,
notes: provider.notes,
websiteUrl: provider.websiteUrl,
// 若读取到实时配置则优先使用
settingsConfig: initialSettingsConfig,
category: provider.category,
meta: provider.meta,
icon: provider.icon,
iconColor: provider.iconColor,
}}
initialData={initialData}
showButtons={false}
/>
</FullScreenPanel>
@@ -7,6 +7,7 @@ import {
Play,
TestTube2,
Trash2,
RotateCcw,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -21,6 +22,9 @@ interface ProviderActionsProps {
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
onResetCircuitBreaker?: () => void;
isProxyTarget?: boolean;
consecutiveFailures?: number;
}
export function ProviderActions({
@@ -32,6 +36,9 @@ export function ProviderActions({
onTest,
onConfigureUsage,
onDelete,
onResetCircuitBreaker,
isProxyTarget,
consecutiveFailures = 0,
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
@@ -110,6 +117,32 @@ export function ProviderActions({
<BarChart3 className="h-4 w-4" />
</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
size="icon"
variant="ghost"
+145 -12
View File
@@ -13,6 +13,13 @@ import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import { Switch } from "@/components/ui/switch";
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 {
attributes: DraggableAttributes;
@@ -32,8 +39,9 @@ interface ProviderCardProps {
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
isTesting?: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
dragHandleProps?: DragHandleProps;
}
@@ -84,12 +92,109 @@ export function ProviderCard({
onDuplicate,
onTest,
isTesting,
onSetProxyTarget,
isProxyRunning,
proxyPriority,
allProviders,
dragHandleProps,
}: ProviderCardProps) {
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", {
defaultValue: "未配置接口地址",
});
@@ -124,9 +229,9 @@ export function ProviderCard({
return (
<div
className={cn(
"glass-card relative overflow-hidden rounded-xl p-4 transition-all duration-300",
"group hover:bg-black/[0.02] dark:hover:bg-white/[0.02] hover:border-primary/50",
isCurrent ? "glass-card-active" : "hover:scale-[1.01]",
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
"bg-card text-card-foreground group hover:border-border-active",
isCurrent ? "border-primary/50 shadow-sm" : "hover:shadow-sm",
dragHandleProps?.isDragging &&
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
)}
@@ -149,7 +254,7 @@ export function ProviderCard({
</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
icon={provider.icon}
name={provider.name}
@@ -163,6 +268,29 @@ export function ProviderCard({
<h3 className="text-base font-semibold leading-none">
{provider.name}
</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.meta?.isPartner && (
<span
@@ -185,17 +313,15 @@ export function ProviderCard({
id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => {
if (checked && !provider.isProxyTarget) {
onSetProxyTarget(provider);
}
handleSetProxyTarget(checked);
}}
disabled={provider.isProxyTarget}
className="scale-75 data-[state=checked]:bg-purple-500"
disabled={setProxyTargetMutation.isPending}
className="scale-75 data-[state=checked]:bg-green-500"
/>
{provider.isProxyTarget && (
<Label
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: "代理目标" })}
</Label>
@@ -255,6 +381,13 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onResetCircuitBreaker={
isProxyRunning && provider.isProxyTarget
? handleResetCircuitBreaker
: undefined
}
isProxyTarget={provider.isProxyTarget}
consecutiveFailures={health?.consecutive_failures ?? 0}
/>
</div>
</div>
@@ -10,7 +10,7 @@ export function ProviderEmptyState({ onCreate }: ProviderEmptyStateProps) {
const { t } = useTranslation();
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">
<Users className="h-7 w-7 text-muted-foreground" />
</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,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useMemo } from "react";
import type { CSSProperties } from "react";
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { useDragSort } from "@/hooks/useDragSort";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useModelTest } from "@/hooks/useModelTest";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
@@ -26,7 +26,7 @@ interface ProviderListProps {
onOpenWebsite: (url: string) => void;
onCreate?: () => void;
isLoading?: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning?: boolean; // 代理服务运行状态
}
export function ProviderList({
@@ -41,19 +41,37 @@ export function ProviderList({
onOpenWebsite,
onCreate,
isLoading = false,
onSetProxyTarget,
isProxyRunning = false, // 默认值为 false
}: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers,
appId,
);
// 获取代理服务运行状态
const { isRunning: isProxyRunning } = useProxyStatus();
// 模型测试
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) => {
testProvider(provider.id, provider.name);
};
@@ -103,8 +121,9 @@ export function ProviderList({
onOpenWebsite={onOpenWebsite}
onTest={handleTest}
isTesting={isTesting(provider.id)}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
proxyPriority={proxyPriorityMap.get(provider.id)}
allProviders={sortedProviders}
/>
))}
</div>
@@ -125,8 +144,9 @@ interface SortableProviderCardProps {
onOpenWebsite: (url: string) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
onSetProxyTarget: (provider: Provider) => void;
isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表
}
function SortableProviderCard({
@@ -141,8 +161,9 @@ function SortableProviderCard({
onOpenWebsite,
onTest,
isTesting,
onSetProxyTarget,
isProxyRunning,
proxyPriority,
allProviders,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -174,8 +195,9 @@ function SortableProviderCard({
onOpenWebsite={onOpenWebsite}
onTest={onTest}
isTesting={isTesting}
onSetProxyTarget={onSetProxyTarget}
isProxyRunning={isProxyRunning}
proxyPriority={proxyPriority}
allProviders={allProviders}
dragHandleProps={{
attributes,
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 { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Activity, Clock, TrendingUp, Server, ListOrdered } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { Settings, Activity, Clock, TrendingUp, Server } from "lucide-react";
import { ProxySettingsDialog } from "./ProxySettingsDialog";
import { toast } from "sonner";
import { useProxyTargets } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
import type { ProxyStatus } from "@/types/proxy";
export function ProxyPanel() {
const { status, isRunning, start, stop, isPending } = useProxyStatus();
const { status, isRunning } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
const handleToggle = async () => {
try {
if (isRunning) {
await stop();
} else {
await start();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
}
};
// 获取所有三个应用类型的代理目标列表
const { data: claudeTargets = [] } = useProxyTargets("claude");
const { data: codexTargets = [] } = useProxyTargets("codex");
const { data: geminiTargets = [] } = useProxyTargets("gemini");
const formatUptime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
@@ -39,58 +34,12 @@ export function ProxyPanel() {
return (
<>
<section className="space-y-6 rounded-xl border border-white/10 glass-card p-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>
<section className="space-y-6">
{isRunning && status ? (
<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>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
</p>
<p className="text-xs text-muted-foreground"></p>
<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">
http://{status.address}:{status.port}
@@ -110,16 +59,14 @@ export function ProxyPanel() {
</div>
</div>
<div className="pt-3 border-t border-white/10 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
</p>
<div className="pt-3 border-t border-border space-y-2">
<p className="text-xs text-muted-foreground">使</p>
{status.active_targets && status.active_targets.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{status.active_targets.map((target) => (
<div
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">
{target.app_type}
@@ -146,6 +93,50 @@ export function ProxyPanel() {
</p>
)}
</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 className="grid gap-3 md:grid-cols-4">
@@ -208,15 +199,114 @@ function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
return (
<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">
{icon}
<span className="text-xs font-medium uppercase tracking-wide">
{label}
</span>
<span className="text-xs">{label}</span>
</div>
<p className="text-xl font-semibold text-foreground">{value}</p>
</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 { Download, ExternalLink, Info, Loader2, RefreshCw } from "lucide-react";
import {
Download,
ExternalLink,
Info,
Loader2,
RefreshCw,
Terminal,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -7,16 +16,27 @@ import { getVersion } from "@tauri-apps/api/app";
import { settingsApi } from "@/lib/api";
import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
interface AboutSectionProps {
isPortable: boolean;
}
interface ToolVersion {
name: string;
version: string | null;
latest_version: string | null;
error: string | null;
}
export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
const { t } = useTranslation();
const [version, setVersion] = useState<string | null>(null);
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
const [isDownloading, setIsDownloading] = useState(false);
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
const [isLoadingTools, setIsLoadingTools] = useState(true);
const {
hasUpdate,
@@ -31,18 +51,24 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
let active = true;
const load = async () => {
try {
const loaded = await getVersion();
const [appVersion, tools] = await Promise.all([
getVersion(),
settingsApi.getToolVersions(),
]);
if (active) {
setVersion(loaded);
setVersion(appVersion);
setToolVersions(tools);
}
} catch (error) {
console.error("[AboutSection] Failed to get version", error);
console.error("[AboutSection] Failed to load info", error);
if (active) {
setVersion(null);
}
} finally {
if (active) {
setIsLoadingVersion(false);
setIsLoadingTools(false);
}
}
};
@@ -53,6 +79,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
};
}, []);
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
const handleOpenReleaseNotes = useCallback(async () => {
try {
const targetVersion = updateInfo?.availableVersion ?? version ?? "";
@@ -125,7 +153,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const displayVersion = version ?? t("common.unknown");
return (
<section className="space-y-4">
<section className="space-y-6">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("common.about")}</h3>
<p className="text-xs text-muted-foreground">
@@ -133,24 +161,28 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</p>
</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="space-y-1">
<p className="text-sm font-medium text-foreground">CC Switch</p>
<p className="text-xs text-muted-foreground">
{t("common.version")}{" "}
{isLoadingVersion ? (
<Loader2 className="inline h-3 w-3 animate-spin" />
) : (
`v${displayVersion}`
<div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background">
<span className="text-muted-foreground">
{t("common.version")}
</span>
{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>
{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">
@@ -159,6 +191,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
variant="outline"
size="sm"
onClick={handleOpenReleaseNotes}
className="h-9"
>
<ExternalLink className="mr-2 h-4 w-4" />
{t("settings.releaseNotes")}
@@ -168,7 +201,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm"
onClick={handleCheckUpdate}
disabled={isChecking || isDownloading}
className="min-w-[140px]"
className="min-w-[140px] h-9"
>
{isDownloading ? (
<span className="inline-flex items-center gap-2">
@@ -194,18 +227,71 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div>
</div>
{hasUpdate && updateInfo ? (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<p>
{hasUpdate && updateInfo && (
<div className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm">
<p className="font-medium text-primary mb-1">
{t("settings.updateAvailable", {
version: updateInfo.availableVersion,
})}
</p>
{updateInfo.notes ? (
<p className="mt-1 line-clamp-3">{updateInfo.notes}</p>
) : null}
{updateInfo.notes && (
<p className="text-muted-foreground line-clamp-3 leading-relaxed">
{updateInfo.notes}
</p>
)}
</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>
</section>
);
@@ -50,7 +50,7 @@ export function DirectorySettings({
<Input
value={appConfigDir ?? resolvedDirs.appConfig ?? ""}
placeholder={t("settings.browsePlaceholderApp")}
className="font-mono text-xs"
className="text-xs"
onChange={(event) => onAppConfigChange(event.target.value)}
/>
<Button
@@ -161,7 +161,7 @@ function DirectoryInput({
<Input
value={displayValue}
placeholder={placeholder}
className="font-mono text-xs"
className="text-xs"
onChange={(event) => onChange(event.target.value)}
/>
<Button
+241 -42
View File
@@ -1,5 +1,13 @@
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 {
Dialog,
@@ -8,6 +16,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
@@ -20,11 +34,15 @@ import { AboutSection } from "@/components/settings/AboutSection";
import { ProxyPanel } from "@/components/proxy";
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
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 {
open: boolean;
@@ -154,6 +172,26 @@ export function SettingsPage({
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 (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? (
@@ -198,61 +236,225 @@ export function SettingsPage({
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
{settings ? (
<>
<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}
/>
<div className="space-y-4">
<Accordion
type="multiple"
defaultValue={[]}
className="w-full space-y-4"
>
<AccordionItem
value="directory"
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">
<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>
{/* 代理服务面板 */}
<ProxyPanel />
<AccordionItem
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>
{/* 模型定价配置 */}
<PricingConfigPanel />
<AccordionItem
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>
{/* 模型测试配置 */}
<ModelTestConfigPanel />
<AccordionItem
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
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
<div className="pt-6 border-t border-gray-200 dark:border-white/10">
<AccordionItem
value="pricing"
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">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<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
onClick={handleSave}
className="w-full"
className="w-full h-12 text-base font-medium"
disabled={isSaving}
>
{isSaving ? (
<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")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
<Save className="mr-2 h-5 w-5" />
{t("common.save")}
</>
)}
</Button>
</div>
</>
</div>
) : null}
</TabsContent>
@@ -271,10 +473,7 @@ export function SettingsPage({
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent
zIndex="alert"
className="max-w-md glass border-white/10"
>
<DialogContent zIndex="alert" className="max-w-md glass border-border">
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
@@ -287,7 +486,7 @@ export function SettingsPage({
<Button
variant="ghost"
onClick={handleRestartLater}
className="hover:bg-white/5"
className="hover:bg-muted/50"
>
{t("settings.restartLater")}
</Button>
+44 -31
View File
@@ -1,6 +1,7 @@
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power } from "lucide-react";
interface WindowSettingsProps {
settings: SettingsFormState;
@@ -12,40 +13,46 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
return (
<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>
<p className="text-xs text-muted-foreground">
{t("settings.windowBehaviorHint")}
</p>
</header>
</div>
<ToggleRow
title={t("settings.launchOnStartup")}
description={t("settings.launchOnStartupDescription")}
checked={!!settings.launchOnStartup}
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<div className="space-y-3">
<ToggleRow
icon={<Power className="h-4 w-4 text-orange-500" />}
title={t("settings.launchOnStartup")}
description={t("settings.launchOnStartupDescription")}
checked={!!settings.launchOnStartup}
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<ToggleRow
title={t("settings.minimizeToTray")}
description={t("settings.minimizeToTrayDescription")}
checked={settings.minimizeToTrayOnClose}
onCheckedChange={(value) => onChange({ minimizeToTrayOnClose: value })}
/>
<ToggleRow
icon={<AppWindow className="h-4 w-4 text-blue-500" />}
title={t("settings.minimizeToTray")}
description={t("settings.minimizeToTrayDescription")}
checked={settings.minimizeToTrayOnClose}
onCheckedChange={(value) =>
onChange({ minimizeToTrayOnClose: value })
}
/>
<ToggleRow
title={t("settings.enableClaudePluginIntegration")}
description={t("settings.enableClaudePluginIntegrationDescription")}
checked={!!settings.enableClaudePluginIntegration}
onCheckedChange={(value) =>
onChange({ enableClaudePluginIntegration: value })
}
/>
<ToggleRow
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
title={t("settings.enableClaudePluginIntegration")}
description={t("settings.enableClaudePluginIntegrationDescription")}
checked={!!settings.enableClaudePluginIntegration}
onCheckedChange={(value) =>
onChange({ enableClaudePluginIntegration: value })
}
/>
</div>
</section>
);
}
interface ToggleRowProps {
icon: React.ReactNode;
title: string;
description?: string;
checked: boolean;
@@ -53,18 +60,24 @@ interface ToggleRowProps {
}
function ToggleRow({
icon,
title,
description,
checked,
onCheckedChange,
}: ToggleRowProps) {
return (
<div className="flex items-start justify-between gap-4 rounded-lg border border-border-default p-4">
<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 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="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
{icon}
</div>
<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>
<Switch
checked={checked}
+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 (
<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>
<TableHeader>
<TableRow>
+111 -159
View File
@@ -1,17 +1,10 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react";
import { Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
getModelTestConfig,
@@ -21,7 +14,6 @@ import {
export function ModelTestConfigPanel() {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -66,160 +58,120 @@ export function ModelTestConfigPanel() {
if (isLoading) {
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer"
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>
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer select-none"
onClick={() => setIsExpanded(!isExpanded)}
>
<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>
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</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 { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -110,136 +104,107 @@ export function PricingConfigPanel() {
}
return (
<Card className="border rounded-lg">
<CardHeader
className="cursor-pointer select-none"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<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("usage.modelPricing", "模型定价")}
{pricing && pricing.length > 0 && (
<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>
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc", "配置各模型的 Token 成本")} ()
</h4>
<Button
onClick={(e) => {
e.stopPropagation();
handleAddNew();
}}
size="sm"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add", "新增")}
</Button>
</div>
{isExpanded && (
<CardContent>
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>
{t(
"usage.noPricingData",
'暂无定价数据。点击"新增"添加模型定价配置。',
)}
</AlertDescription>
</Alert>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model", "模型")}</TableHead>
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
<TableHead className="text-right">
{t("usage.inputCost", "输入成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputCost", "输出成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheReadCost", "缓存读取")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheWriteCost", "缓存写入")}
</TableHead>
<TableHead className="text-right">
{t("common.actions", "操作")}
</TableHead>
<div className="space-y-4">
{!pricing || pricing.length === 0 ? (
<Alert>
<AlertDescription>
{t(
"usage.noPricingData",
'暂无定价数据。点击"新增"添加模型定价配置。',
)}
</AlertDescription>
</Alert>
) : (
<div className="rounded-md bg-card/60 shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("usage.model", "模型")}</TableHead>
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
<TableHead className="text-right">
{t("usage.inputCost", "输入成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.outputCost", "输出成本")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheReadCost", "缓存读取")}
</TableHead>
<TableHead className="text-right">
{t("usage.cacheWriteCost", "缓存写入")}
</TableHead>
<TableHead className="text-right">
{t("common.actions", "操作")}
</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>
</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>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
)}
))}
</TableBody>
</Table>
</div>
)}
</div>
{editingModel && (
<PricingEditModal
@@ -284,6 +249,6 @@ export function PricingConfigPanel() {
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function ProviderStatsTable() {
}
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>
<TableHeader>
<TableRow>
+142 -114
View File
@@ -65,123 +65,151 @@ export function RequestLogTable() {
return (
<div className="space-y-4">
{/* 筛选栏 */}
<div className="flex flex-wrap items-center gap-2 rounded-md bg-card/60 p-3 shadow-sm">
<Select
value={tempFilters.appType || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
appType: v === "all" ? undefined : v,
})
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("usage.endpoint", "端点")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-3">
<Select
value={tempFilters.appType || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
appType: v === "all" ? undefined : v,
})
}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.endpoint", "端点")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部端点")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
<Select
value={tempFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v),
})
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("usage.status", "状态码")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部")}</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="400">400</SelectItem>
<SelectItem value="401">401</SelectItem>
<SelectItem value="429">429</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
<Select
value={tempFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setTempFilters({
...tempFilters,
statusCode: v === "all" ? undefined : parseInt(v),
})
}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder={t("usage.status", "状态码")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all", "全部状态")}</SelectItem>
<SelectItem value="200">200 OK</SelectItem>
<SelectItem value="400">400 Bad Request</SelectItem>
<SelectItem value="401">401 Unauthorized</SelectItem>
<SelectItem value="429">429 Rate Limit</SelectItem>
<SelectItem value="500">500 Server Error</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t("usage.provider", "供应商名称")}
className="w-[140px]"
value={tempFilters.providerName || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
providerName: e.target.value || undefined,
})
}
/>
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("usage.provider", "搜索供应商...")}
className="pl-9 bg-background"
value={tempFilters.providerName || ""}
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
placeholder={t("usage.model", "模型名称")}
className="w-[140px]"
value={tempFilters.model || ""}
onChange={(e) =>
setTempFilters({
...tempFilters,
model: e.target.value || undefined,
})
}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">:</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
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
type="datetime-local"
className="w-[180px]"
value={
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,
})
}
/>
<Input
type="datetime-local"
className="w-[180px]"
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 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 className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="default"
onClick={handleSearch}
className="h-8"
>
<Search className="mr-2 h-3.5 w-3.5" />
{t("common.search", "查询")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleReset}
className="h-8"
>
<X className="mr-2 h-3.5 w-3.5" />
{t("common.reset", "重置")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleRefresh}
className="h-8 px-2"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</div>
@@ -189,7 +217,7 @@ export function RequestLogTable() {
<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>
<TableHeader>
<TableRow>
+77 -36
View File
@@ -1,13 +1,14 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { UsageSummaryCards } from "./UsageSummaryCards";
import { UsageTrendChart } from "./UsageTrendChart";
import { RequestLogTable } from "./RequestLogTable";
import { ProviderStatsTable } from "./ProviderStatsTable";
import { ModelStatsTable } from "./ModelStatsTable";
import type { TimeRange } from "@/types/usage";
import { motion } from "framer-motion";
import { BarChart3, ListFilter, Activity } from "lucide-react";
export function UsageDashboard() {
const { t } = useTranslation();
@@ -16,50 +17,90 @@ export function UsageDashboard() {
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
return (
<div className="space-y-6">
<div className="flex items-center justify-end">
<select
<motion.div
initial={{ opacity: 0, y: 10 }}
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}
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
className="rounded-md border px-3 py-1.5 text-sm"
onValueChange={(v) => setTimeRange(v as TimeRange)}
className="w-full sm:w-auto"
>
<option value="1d">{t("usage.today", "今天")}</option>
<option value="7d">{t("usage.last7days", "过去 7 天")}</option>
<option value="30d">{t("usage.last30days", "过去 30 天")}</option>
</select>
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
<TabsTrigger
value="1d"
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
>
{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>
<UsageSummaryCards days={days} />
<Card className="border-none bg-transparent p-0 shadow-none">
<UsageTrendChart days={days} />
</Card>
<UsageTrendChart days={days} />
<Tabs defaultValue="logs" className="w-full">
<TabsList>
<TabsTrigger value="logs">
{t("usage.requestLogs", "请求日志")}
</TabsTrigger>
<TabsTrigger value="providers">
{t("usage.providerStats", "Provider 统计")}
</TabsTrigger>
<TabsTrigger value="models">
{t("usage.modelStats", "模型统计")}
</TabsTrigger>
</TabsList>
<div className="space-y-4">
<Tabs defaultValue="logs" className="w-full">
<div className="flex items-center justify-between mb-4">
<TabsList className="bg-muted/50">
<TabsTrigger value="logs" className="gap-2">
<ListFilter className="h-4 w-4" />
{t("usage.requestLogs", "请求日志")}
</TabsTrigger>
<TabsTrigger value="providers" className="gap-2">
<Activity className="h-4 w-4" />
{t("usage.providerStats", "Provider 统计")}
</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">
<RequestLogTable />
</TabsContent>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<TabsContent value="logs" className="mt-0">
<RequestLogTable />
</TabsContent>
<TabsContent value="providers" className="mt-4">
<ProviderStatsTable />
</TabsContent>
<TabsContent value="providers" className="mt-0">
<ProviderStatsTable />
</TabsContent>
<TabsContent value="models" className="mt-4">
<ModelStatsTable />
</TabsContent>
</Tabs>
</div>
<TabsContent value="models" className="mt-0">
<ModelStatsTable />
</TabsContent>
</motion.div>
</Tabs>
</div>
</motion.div>
);
}
+141 -85
View File
@@ -1,6 +1,9 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { useUsageSummary } from "@/lib/query/usage";
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
import { motion } from "framer-motion";
interface UsageSummaryCardsProps {
days: number;
@@ -8,29 +11,118 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
const { t } = useTranslation();
const endDate = Math.floor(Date.now() / 1000);
const startDate = endDate - days * 24 * 60 * 60;
const { startDate, endDate } = useMemo(() => {
const end = Math.floor(Date.now() / 1000);
const start = end - days * 24 * 60 * 60;
return { startDate: start, endDate: end };
}, [days]);
const { data: summary, isLoading } = useUsageSummary(startDate, endDate);
const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFloat(summary?.totalCost || "0").toFixed(4);
const totalInputTokens = summary?.totalInputTokens ?? 0;
const totalOutputTokens = summary?.totalOutputTokens ?? 0;
const totalTokens = totalInputTokens + totalOutputTokens;
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
const stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0;
const totalCost = parseFloat(summary?.totalCost || "0");
const inputTokens = summary?.totalInputTokens ?? 0;
const outputTokens = summary?.totalOutputTokens ?? 0;
const totalTokens = inputTokens + outputTokens;
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
return [
{
title: t("usage.totalRequests", "总请求数"),
value: totalRequests.toLocaleString(),
icon: Activity,
color: "text-blue-500",
bg: "bg-blue-500/10",
subValue: null,
},
{
title: t("usage.totalCost", "总成本"),
value: `$${totalCost.toFixed(4)}`,
icon: DollarSign,
color: "text-green-500",
bg: "bg-green-500/10",
subValue: null,
},
{
title: t("usage.totalTokens", "总 Token 数"),
value: totalTokens.toLocaleString(),
icon: Layers,
color: "text-purple-500",
bg: "bg-purple-500/10",
subValue: (
<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) {
return (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<div className="h-4 w-24 animate-pulse rounded bg-gray-200" />
</CardHeader>
<CardContent>
<div className="h-8 w-32 animate-pulse rounded bg-gray-200" />
<Card
key={i}
className="border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm"
>
<CardContent className="p-6 flex items-center justify-center min-h-[160px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
</CardContent>
</Card>
))}
@@ -39,75 +131,39 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
}
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.totalRequests", "总请求数")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{totalRequests.toLocaleString()}
</div>
</CardContent>
</Card>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-4 md:grid-cols-4"
>
{stats.map((stat, i) => (
<motion.div key={i} variants={item}>
<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">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-2">
<p className="text-sm font-medium text-muted-foreground">
{stat.title}
</p>
<div className={`p-2 rounded-lg ${stat.bg}`}>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</div>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.totalCost", "总成本")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${totalCost}</div>
</CardContent>
</Card>
<div className="space-y-1">
<h3 className="text-2xl font-bold truncate" title={stat.value}>
{stat.value}
</h3>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("usage.totalTokens", "总 Token 数")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{totalTokens.toLocaleString()}
</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>
{stat.subValue || (
/* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */
<div className="mt-3 pt-3 border-t border-transparent h-[52px]"></div>
)}
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
);
}
+124 -90
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
@@ -10,6 +10,7 @@ import {
Legend,
} from "recharts";
import { useUsageTrends } from "@/lib/query/usage";
import { Loader2 } from "lucide-react";
interface UsageTrendChartProps {
days: number;
@@ -20,7 +21,11 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
const { data: trends, isLoading } = useUsageTrends(days);
if (isLoading) {
return <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;
@@ -38,8 +43,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
hour: pointDate.getHours(),
inputTokens: stat.totalInputTokens,
outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost),
};
}) || [];
@@ -56,8 +59,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
label: `${hour.toString().padStart(2, "0")}:00`,
inputTokens: bucket?.inputTokens ?? 0,
outputTokens: bucket?.outputTokens ?? 0,
cacheCreationTokens: bucket?.cacheCreationTokens ?? 0,
cacheReadTokens: bucket?.cacheReadTokens ?? 0,
cost: bucket?.cost ?? 0,
};
});
@@ -65,96 +66,129 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
const displayData = isToday ? hourlyData : chartData;
const rangeLabel = isToday
? t("usage.rangeToday", "今天 (按小时)")
: days === 7
? t("usage.rangeLast7Days", "过去 7 天")
: t("usage.rangeLast30Days", "过去 30 天");
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-md">
<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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="rounded-xl border border-border/50 bg-card/40 p-6 backdrop-blur-sm">
<div className="mb-6 flex items-center justify-between">
<h3 className="text-lg font-semibold">
{t("usage.trends", "使用趋势")}
</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>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={displayData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" />
<YAxis
yAxisId="tokens"
label={{
value: t("usage.tokensAxis", "Tokens"),
angle: -90,
position: "insideLeft",
}}
/>
<YAxis
yAxisId="cost"
orientation="right"
label={{
value: t("usage.costAxis", "成本 (USD)"),
angle: 90,
position: "insideRight",
}}
/>
<Tooltip />
<Legend />
<Line
yAxisId="tokens"
type="monotone"
dataKey="inputTokens"
name={t("usage.inputTokens", "输入 Tokens")}
stroke="#2563eb"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="tokens"
type="monotone"
dataKey="outputTokens"
name={t("usage.outputTokens", "输出 Tokens")}
stroke="#16a34a"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="tokens"
type="monotone"
dataKey="cacheCreationTokens"
name={t("usage.cacheCreationTokens", "缓存写入")}
stroke="#f97316"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="tokens"
type="monotone"
dataKey="cacheReadTokens"
name={t("usage.cacheReadTokens", "缓存读取")}
stroke="#a855f7"
strokeWidth={2}
dot={false}
isAnimationActive
/>
<Line
yAxisId="cost"
type="monotone"
dataKey="cost"
name={t("usage.cost", "成本")}
stroke="#dc2626"
strokeWidth={2}
dot={false}
isAnimationActive
/>
</LineChart>
</ResponsiveContainer>
<div className="h-[350px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={displayData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorInput" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorOutput" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="hsl(var(--border))"
opacity={0.4}
/>
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
dy={10}
/>
<YAxis
yAxisId="tokens"
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
/>
<YAxis
yAxisId="cost"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
yAxisId="tokens"
type="monotone"
dataKey="inputTokens"
name={t("usage.inputTokens", "输入 Tokens")}
stroke="#3b82f6"
fillOpacity={1}
fill="url(#colorInput)"
strokeWidth={2}
/>
<Area
yAxisId="tokens"
type="monotone"
dataKey="outputTokens"
name={t("usage.outputTokens", "输出 Tokens")}
stroke="#22c55e"
fillOpacity={1}
fill="url(#colorOutput)"
strokeWidth={2}
/>
<Area
yAxisId="cost"
type="monotone"
dataKey="cost"
name={t("usage.cost", "成本")}
stroke="#f43f5e"
fill="none"
strokeWidth={2}
strokeDasharray="4 4"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
+1 -13
View File
@@ -9,7 +9,6 @@ import {
useUpdateProviderMutation,
useDeleteProviderMutation,
useSwitchProviderMutation,
useSetProxyTargetMutation,
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
@@ -25,7 +24,6 @@ export function useProviderActions(activeApp: AppId) {
const updateProviderMutation = useUpdateProviderMutation(activeApp);
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
const switchProviderMutation = useSwitchProviderMutation(activeApp);
const setProxyTargetMutation = useSetProxyTargetMutation(activeApp);
// Claude 插件同步逻辑
const syncClaudePlugin = useCallback(
@@ -93,14 +91,6 @@ export function useProviderActions(activeApp: AppId) {
[switchProviderMutation, syncClaudePlugin],
);
// 设置代理目标
const setProxyTarget = useCallback(
async (provider: Provider) => {
await setProxyTargetMutation.mutateAsync(provider.id);
},
[setProxyTargetMutation],
);
// 删除供应商
const deleteProvider = useCallback(
async (id: string) => {
@@ -146,14 +136,12 @@ export function useProviderActions(activeApp: AppId) {
addProvider,
updateProvider,
switchProvider,
setProxyTarget,
deleteProvider,
saveUsageScript,
isLoading:
addProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending ||
switchProviderMutation.isPending ||
setProxyTargetMutation.isPending,
switchProviderMutation.isPending,
};
}
+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> {
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");
},
};
+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 秒刷新一次
});
}
+16 -12
View File
@@ -36,9 +36,10 @@ export const useAddProviderMutation = (appId: AppId) => {
toast.success(
t("notifications.providerAdded", {
defaultValue: "供应商已添加",
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {
@@ -66,9 +67,10 @@ export const useUpdateProviderMutation = (appId: AppId) => {
toast.success(
t("notifications.updateSuccess", {
defaultValue: "供应商更新成功",
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {
@@ -106,9 +108,10 @@ export const useDeleteProviderMutation = (appId: AppId) => {
toast.success(
t("notifications.deleteSuccess", {
defaultValue: "供应商已删除",
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {
@@ -147,9 +150,10 @@ export const useSwitchProviderMutation = (appId: AppId) => {
t("notifications.switchSuccess", {
defaultValue: "切换供应商成功",
appName: t(`apps.${appId}`, { defaultValue: appId }),
}), {
closeButton: true
}
}),
{
closeButton: true,
},
);
},
onError: (error: Error) => {
+10
View File
@@ -34,12 +34,22 @@ export interface ProvidersQueryData {
currentProviderId: string;
}
export interface UseProvidersQueryOptions {
isProxyRunning?: boolean; // 代理服务是否运行中
}
export const useProvidersQuery = (
appId: AppId,
options?: UseProvidersQueryOptions,
): UseQueryResult<ProvidersQueryData> => {
const { isProxyRunning = false } = options || {};
return useQuery({
queryKey: ["providers", appId],
placeholderData: keepPreviousData,
// 当代理服务运行时,每 10 秒刷新一次供应商列表
// 这样可以自动反映后端熔断器自动禁用代理目标的变更
refetchInterval: isProxyRunning ? 10000 : false,
queryFn: async () => {
let providers: Record<string, Provider> = {};
let currentProviderId = "";
+33
View File
@@ -48,6 +48,39 @@ export interface ProviderHealth {
updated_at: string;
}
// 熔断器相关类型
export interface CircuitBreakerConfig {
failureThreshold: number;
successThreshold: number;
timeoutSeconds: number;
errorRateThreshold: number;
minRequests: number;
}
export type CircuitState = "closed" | "open" | "half_open";
export interface CircuitBreakerStats {
state: CircuitState;
consecutiveFailures: number;
consecutiveSuccesses: number;
totalRequests: number;
failedRequests: number;
}
// 供应商健康状态枚举
export enum ProviderHealthStatus {
Healthy = "healthy",
Degraded = "degraded",
Failed = "failed",
Unknown = "unknown",
}
// 扩展 ProviderHealth 以包含前端计算的状态
export interface ProviderHealthWithStatus extends ProviderHealth {
status: ProviderHealthStatus;
circuitState?: CircuitState;
}
export interface ProxyUsageRecord {
provider_id: string;
app_type: string;
+165 -112
View File
@@ -4,119 +4,172 @@ export default {
"./src/index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "selector",
darkMode: ["selector", "class"],
theme: {
extend: {
colors: {
// shadcn/ui CSS 变量映射
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
// macOS 风格系统蓝
blue: {
400: '#409CFF',
500: '#0A84FF',
600: '#0060DF',
},
// 自定义灰色系列(对齐 macOS 深色 System Gray
gray: {
50: '#fafafa', // bg-primary
100: '#f4f4f5', // bg-tertiary
200: '#e4e4e7', // border
300: '#d4d4d8', // border-hover
400: '#a1a1aa', // text-tertiary
500: '#71717a', // text-secondary
600: '#636366', // text-secondary-dark / systemGray2
700: '#48484A', // bg-tertiary-dark / separators
800: '#3A3A3C', // bg-secondary-dark
900: '#2C2C2E', // header / modal bg
950: '#1C1C1E', // app main bg
},
// 状态颜色
green: {
500: '#10b981',
100: '#d1fae5',
},
red: {
500: '#ef4444',
100: '#fee2e2',
},
amber: {
500: '#f59e0b',
100: '#fef3c7',
},
},
boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
},
borderRadius: {
'sm': '0.375rem',
'md': '0.5rem',
'lg': '0.75rem',
'xl': '0.875rem',
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', '"SF Mono"', 'Consolas', '"Liberation Mono"', 'Menlo', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.5s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'slide-in-right': 'slideInRight 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-100%)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideInRight: {
'0%': { transform: 'translateX(100%)', opacity: '0' },
'100%': { transform: 'translateX(0)', opacity: '1' },
}
}
},
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
blue: {
'400': '#409CFF',
'500': '#0A84FF',
'600': '#0060DF'
},
gray: {
'50': '#fafafa',
'100': '#f4f4f5',
'200': '#e4e4e7',
'300': '#d4d4d8',
'400': '#a1a1aa',
'500': '#71717a',
'600': '#636366',
'700': '#48484A',
'800': '#3A3A3C',
'900': '#2C2C2E',
'950': '#1C1C1E'
},
green: {
'100': '#d1fae5',
'500': '#10b981'
},
red: {
'100': '#fee2e2',
'500': '#ef4444'
},
amber: {
'100': '#fef3c7',
'500': '#f59e0b'
}
},
boxShadow: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)'
},
borderRadius: {
sm: '0.375rem',
md: '0.5rem',
lg: '0.75rem',
xl: '0.875rem'
},
fontFamily: {
// 使用与之前版本保持一致的系统字体栈
sans: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
],
mono: [
'ui-monospace',
'SFMono-Regular',
'"SF Mono"',
'Consolas',
'"Liberation Mono"',
'Menlo',
'monospace',
],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.5s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'slide-in-right': 'slideInRight 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
},
keyframes: {
fadeIn: {
'0%': {
opacity: '0'
},
'100%': {
opacity: '1'
}
},
slideUp: {
'0%': {
transform: 'translateY(20px)',
opacity: '0'
},
'100%': {
transform: 'translateY(0)',
opacity: '1'
}
},
slideDown: {
'0%': {
transform: 'translateY(-100%)',
opacity: '0'
},
'100%': {
transform: 'translateY(0)',
opacity: '1'
}
},
slideInRight: {
'0%': {
transform: 'translateX(100%)',
opacity: '0'
},
'100%': {
transform: 'translateX(0)',
opacity: '1'
}
},
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
}
}
},
plugins: [],
}
-3
View File
@@ -125,7 +125,6 @@ describe("ProviderList Component", () => {
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()}
onSetProxyTarget={vi.fn()}
isLoading
/>,
);
@@ -154,7 +153,6 @@ describe("ProviderList Component", () => {
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onOpenWebsite={vi.fn()}
onSetProxyTarget={vi.fn()}
onCreate={handleCreate}
/>,
);
@@ -195,7 +193,6 @@ describe("ProviderList Component", () => {
onDuplicate={handleDuplicate}
onConfigureUsage={handleUsage}
onOpenWebsite={handleOpenWebsite}
onSetProxyTarget={vi.fn()}
/>,
);