Compare commits

...

43 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
Jason bf9228093b fix: add fallback for crypto.randomUUID() on older WebViews
crypto.randomUUID() is not available on older systems (macOS < 12.3,
Safari < 15.4). This caused "crypto.randomUUID is not a function"
error when adding providers.

- Add generateUUID() utility with getRandomValues() fallback
- Provide user-friendly error message if crypto API unavailable
2025-12-04 17:01:17 +08:00
Forte Scarlet 420b5d4389 feat: 为切换弹出提示框增加可关闭按钮 (#350) 2025-12-04 15:04:39 +08:00
wenyuan b1e4c37f9c fix(ui): update SettingsPage tab styles for improved appearance (#342) 2025-12-03 10:21:37 +08:00
farion1231 74a2e9c08b fix(mcp): use browser-compatible platform detection for MCP presets
Replace Node.js process.platform check with navigator.userAgent-based
detection from @/lib/platform. The previous implementation using
process.platform failed in Tauri's browser environment, causing
Windows platform to always return false and not apply the required
'cmd /c' wrapper for npx commands.

This fix ensures that on Windows, MCP presets like sequential-thinking
correctly use 'cmd /c npx' format instead of direct 'npx', eliminating
the warning: "Windows requires 'cmd /c' wrapper to execute npx"

Changes:
- Import isWindows() from @/lib/platform instead of defining locally
- Remove incorrect process.platform-based detection
- Now properly detects Windows in browser/WebView environment
2025-12-02 15:43:14 +08:00
farion1231 169db5b6d8 chore: add nul to .gitignore to prevent Windows reserved filename tracking 2025-12-02 10:00:15 +08:00
Jason 3230b0e094 chore: bump version to 3.8.2 2025-12-01 22:41:43 +08:00
Jason 2347ac0ee0 fix(config): update DeepSeek default model from Exp to stable version 2025-12-01 22:37:01 +08:00
Jason 58c5468bf6 fix(ui): prevent provider card hover scale from being clipped
Move 1px of horizontal padding from outer container to inner scroll
container, providing buffer space for the hover scale-[1.01] effect
without being cut off by overflow-hidden.
2025-12-01 16:03:20 +08:00
Jason 98084d61aa fix(ui): add independent scroll containers to fix scroll wheel on Linux
Providers page was using DndContext which may interfere with scroll wheel
events on Linux/Ubuntu WebKitGTK. Added independent scroll containers
with `overflow-y-auto` to all main pages, matching the pattern already
used by the MCP panel which works correctly.

Changes:
- App.tsx: Wrap ProviderList in independent scroll container
- SkillsPage: Use consistent h-[calc(100vh-8rem)] layout
- SettingsPage: Add overflow-hidden and overflow-x-hidden for consistency
2025-12-01 11:28:01 +08:00
Jason a627e1bb50 fix(config): update Codex default model and correct MiniMax URL
- Update Codex preset model from gpt-5-codex to gpt-5.1-codex
- Fix MiniMax English preset URL from minimaxi.io to minimax.io
2025-12-01 11:08:53 +08:00
Jason 04a588694b chore: bump version to 3.8.1 2025-11-30 23:39:05 +08:00
Jason f5f7dfed8c style: apply code formatting fixes
- Fix Prettier formatting in claudeProviderPresets.ts
- Fix cargo fmt trailing blank line in provider/mod.rs
2025-11-30 23:30:27 +08:00
Jason 5888c56f2a fix(config): correct MiniMax apiKeyUrl to minimaxi.com
Update apiKeyUrl domain to match official website URL.
Also update screenshots for all locales (en, ja, zh).
2025-11-30 23:03:09 +08:00
Jason c5cadb73bc fix(config): correct MiniMax apiKeyUrl domain from minimax.io to minimax.com 2025-11-29 23:03:19 +08:00
Jason 17948ee031 docs: update screenshots and add Japanese locale images
- Update README_JA.md to reference Japanese screenshots
- Add Japanese screenshots (main-ja.png, add-ja.png)
- Update English and Chinese screenshots with latest UI
- Remove outdated README_i18n.md documentation
2025-11-29 22:14:11 +08:00
Jason 2643595012 fix(skills): target first span only when hiding select checkmark indicator 2025-11-29 21:36:16 +08:00
Jason 6ac4d1652c fix(skills): use theme-aware glass-card class for light mode compatibility
Replace hardcoded dark mode styles (bg-gray-900/40, border-white/10) with
the unified glass-card class that adapts to both light and dark themes.
2025-11-29 21:18:51 +08:00
Jason 3bf37cf0ff refactor(url): remove automatic trailing slash stripping from base URL inputs
- Remove `.replace(/\/+$/, "")` from all base URL handlers in useBaseUrlState.ts
- Remove trailing slash stripping in useCodexConfigState.ts
- Remove trailing slash normalization in providerConfigUtils.ts setCodexBaseUrl()
- Update i18n hints (en/ja/zh) to instruct users to avoid trailing slashes

This gives users explicit control over URL format rather than silently modifying input.
2025-11-29 20:52:29 +08:00
Jason 526c7406fa fix(ui): persist active provider card highlight when not hovered
The selected provider's visual highlight (background, border, glow)
was being overridden by .glass-card base styles due to CSS specificity.
Add dedicated .glass-card-active class to ensure active state persists.
2025-11-29 20:19:17 +08:00
Jason 9db85dd4dc style(css): consolidate global selector rules and remove duplicates
- Merge three separate `*` selector blocks into one for better maintainability
- Remove duplicate "Glassmorphism Utilities" comment
2025-11-29 20:03:19 +08:00
Jason 0f333d9e5b fix(tailwind): add missing shadcn/ui color mappings for opaque dialog backgrounds
The dialog backgrounds were transparent because Tailwind 3 requires explicit
color mappings in the config to use CSS variables. Added standard shadcn/ui
color mappings (background, foreground, card, primary, etc.) and removed
unnecessary overlayClassName overrides from dialog components.
2025-11-29 19:49:55 +08:00
Jason ba875552a6 style(mcp): align wizard modal with confirm dialog styling
- Use zIndex="alert" and semi-transparent overlay for consistency
- Apply border-b-0/border-t-0 bg-transparent to header/footer
- Replace emerald accent with blue to match app theme
- Use Input component instead of raw input elements
- Simplify button variants (outline for cancel)
2025-11-29 17:41:12 +08:00
Jason 75e7f9d731 chore: remove dead code from MCP and provider modules
- Remove unused `read_mcp_json` from gemini_mcp.rs
- Remove unused `normalize_server_keys` from mcp/claude.rs
- Remove unused `validate_mcp_entry` from mcp/validation.rs
- Remove unused `write_codex_live`, `write_claude_live`, `app_not_found` from services/provider/mod.rs
- Clean up unused imports
2025-11-29 16:24:22 +08:00
Jason 7e6074a9a9 refactor(mcp): modularize MCP and tray menu logic
Split mcp.rs (1135 lines) into focused modules:
- validation.rs: server config validation
- claude.rs: Claude MCP sync/import
- codex.rs: Codex MCP sync (TOML handling)
- gemini.rs: Gemini MCP sync/import

Extract tray menu logic from lib.rs to tray.rs for better separation of concerns.
2025-11-29 10:29:10 +08:00
138 changed files with 17919 additions and 1453 deletions
+1
View File
@@ -17,3 +17,4 @@ GEMINI.md
/.idea
/.vscode
vitest-report.json
nul
+2 -2
View File
@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.8.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
@@ -51,7 +51,7 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.8.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
### Current Version: v3.8.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.8.0-en.md)
**v3.8.0 Major Update (2025-11-28)**
+4 -4
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
[![Version](https://img.shields.io/badge/version-3.8.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
@@ -46,12 +46,12 @@ GLM CODING PLAN は AI コーディング向けのサブスクリプションで
## スクリーンショット
| メイン画面 | プロバイダ追加 |
| :-------------------------------------------: | :---------------------------------------------: |
| ![メイン画面](assets/screenshots/main-en.png) | ![プロバイダ追加](assets/screenshots/add-en.png) |
| :-------------------------------------------: | :----------------------------------------------: |
| ![メイン画面](assets/screenshots/main-ja.png) | ![プロバイダ追加](assets/screenshots/add-ja.png) |
## 特長
### 現在のバージョン:v3.8.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
### 現在のバージョン:v3.8.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.8.0-en.md)
**v3.8.0 メジャーアップデート (2025-11-28)**
+2 -2
View File
@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.8.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.8.2-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Trending](https://img.shields.io/badge/🔥_TypeScript_Trending-Daily%20%7C%20Weekly%20%7C%20Monthly-ff6b6b.svg)](https://github.com/trending/typescript)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
@@ -51,7 +51,7 @@ CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编
## 功能特性
### 当前版本:v3.8.0 | [完整更新日志](CHANGELOG.md)
### 当前版本:v3.8.2 | [完整更新日志](CHANGELOG.md)
**v3.8.0 重大更新(2025-11-28**
-77
View File
@@ -1,77 +0,0 @@
# CC Switch 国际化功能说明
## 已完成的工作
1. **安装依赖**:添加了 `react-i18next``i18next`
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
3. **翻译文件**:创建了英文、中文、日文翻译文件
4. **组件更新**:替换了主要组件中的硬编码文案
5. **语言切换器**:添加了语言切换按钮
## 文件结构
```
src/
├── i18n/
│ ├── index.ts # 国际化配置文件
│ └── locales/
│ ├── en.json # 英文翻译
│ ├── ja.json # 日文翻译
│ └── zh.json # 中文翻译
├── components/
│ └── LanguageSwitcher.tsx # 语言切换组件
└── main.tsx # 导入国际化配置
```
## 默认语言设置
- **默认语言**:中文 (zh)(无首选时根据浏览器/系统语言选择 zh/en/ja)
- **回退语言**:英文 (en)
## 使用方式
1. 在组件中导入 `useTranslation`
```tsx
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return <div>{t('common.save')}</div>;
}
```
2. 切换语言:
```tsx
const { i18n } = useTranslation();
i18n.changeLanguage('zh'); // 切换到中文
```
## 翻译键结构
- `common.*` - 通用文案(保存、取消、设置等)
- `header.*` - 头部相关文案
- `provider.*` - 供应商相关文案
- `notifications.*` - 通知消息
- `settings.*` - 设置页面文案
- `apps.*` - 应用名称
- `console.*` - 控制台日志信息
## 测试功能
应用已添加了语言切换按钮,支持中文、英文、日文三种语言切换,验证国际化功能是否正常工作。
## 已更新的组件
- ✅ App.tsx - 主应用组件
- ✅ ConfirmDialog.tsx - 确认对话框
- ✅ AddProviderModal.tsx - 添加供应商弹窗
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
- ✅ ProviderList.tsx - 供应商列表
- ✅ LanguageSwitcher.tsx - 语言切换器
- ✅ settings/SettingsDialog.tsx - 设置对话框
## 注意事项
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
2. 翻译键名应该有意义且结构化
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 225 KiB

+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.8.0",
"version": "3.8.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
@@ -26,16 +26,16 @@
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.20",
"cross-fetch": "^4.1.0",
"jsdom": "^25.0.0",
"msw": "^2.11.6",
"postcss": "^8.4.49",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^2.0.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17"
"vitest": "^2.0.5"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@@ -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",
@@ -75,6 +78,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.65.0",
"react-i18next": "^16.0.0",
"recharts": "^3.5.1",
"smol-toml": "^1.4.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
+423
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)
@@ -119,6 +128,9 @@ importers:
react-i18next:
specifier: ^16.0.0
version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
recharts:
specifier: ^3.5.1
version: 3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1)
smol-toml:
specifier: ^1.4.2
version: 1.4.2
@@ -649,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:
@@ -675,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:
@@ -1047,6 +1085,17 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@reduxjs/toolkit@2.11.0':
resolution: {integrity: sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1161,6 +1210,9 @@ packages:
cpu: [x64]
os: [win32]
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -1307,6 +1359,33 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1327,6 +1406,9 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1480,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==}
@@ -1526,6 +1614,50 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -1539,6 +1671,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1609,6 +1744,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.42.0:
resolution: {integrity: sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -1621,6 +1759,9 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
expect-type@1.2.2:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
@@ -1652,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}
@@ -1738,10 +1893,20 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.0.1:
resolution: {integrity: sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==}
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1925,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==}
@@ -2115,6 +2286,18 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -2160,14 +2343,33 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
recharts@3.5.1:
resolution: {integrity: sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -2284,6 +2486,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2392,9 +2597,17 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -3031,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)
@@ -3056,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)
@@ -3416,6 +3662,18 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
dependencies:
'@standard-schema/spec': 1.0.0
'@standard-schema/utils': 0.3.0
immer: 11.0.1
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 18.3.1
react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1)
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.46.2':
@@ -3478,6 +3736,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
'@tanstack/query-core@5.90.3': {}
@@ -3609,6 +3869,30 @@ snapshots:
dependencies:
'@babel/types': 7.28.2
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.8': {}
'@types/node@20.19.9':
@@ -3628,6 +3912,8 @@ snapshots:
'@types/statuses@2.0.6': {}
'@types/use-sync-external-store@0.0.6': {}
'@vitejs/plugin-react@4.7.0(vite@5.4.19(@types/node@20.19.9)(lightningcss@1.30.1))':
dependencies:
'@babel/core': 7.28.0
@@ -3796,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
@@ -3841,6 +4139,44 @@ snapshots:
csstype@3.1.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -3850,6 +4186,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
deep-eql@5.0.2: {}
@@ -3902,6 +4240,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.2
es-toolkit@1.42.0: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -3934,6 +4274,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
eventemitter3@5.0.1: {}
expect-type@1.2.2: {}
fast-glob@3.3.3:
@@ -3966,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
@@ -4051,8 +4402,14 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
immer@10.2.0: {}
immer@11.0.1: {}
indent-string@4.0.0: {}
internmap@2.0.3: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -4202,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):
@@ -4352,6 +4715,15 @@ snapshots:
react-is@17.0.2: {}
react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
redux: 5.0.1
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1):
@@ -4393,13 +4765,41 @@ snapshots:
dependencies:
picomatch: 2.3.1
recharts@3.5.1(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.42.0
eventemitter3: 5.0.1
immer: 10.2.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is: 17.0.2
react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@18.3.1)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redent@3.0.0:
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
require-directory@2.1.1: {}
reselect@5.1.1: {}
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
@@ -4545,6 +4945,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -4629,8 +5031,29 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.23
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
util-deprecate@1.0.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-node@2.1.9(@types/node@20.19.9)(lightningcss@1.30.1):
dependencies:
cac: 6.7.14
+351 -11
View File
@@ -257,6 +257,28 @@ dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "async-task"
version = "4.7.1"
@@ -320,6 +342,61 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -618,14 +695,18 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.8.0"
version = "3.8.2"
dependencies = [
"anyhow",
"async-stream",
"auto-launch",
"axum",
"base64 0.22.1",
"bytes",
"chrono",
"dirs 5.0.1",
"futures",
"hyper",
"indexmap 2.11.4",
"log",
"objc2 0.5.2",
@@ -635,6 +716,7 @@ dependencies = [
"reqwest",
"rquickjs",
"rusqlite",
"rust_decimal",
"serde",
"serde_json",
"serde_yaml",
@@ -650,11 +732,14 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"toml 0.8.2",
"toml_edit 0.22.27",
"tower 0.4.13",
"tower-http 0.5.2",
"url",
"uuid",
"winreg 0.52.0",
"zip 2.4.2",
]
@@ -783,6 +868,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -806,9 +901,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.4",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -819,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.9.4",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -1204,6 +1299,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.0"
@@ -1369,6 +1473,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1376,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1390,6 +1503,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1833,6 +1952,25 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.11.4",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1957,6 +2095,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.7.0"
@@ -1967,9 +2111,11 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -1995,6 +2141,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.17"
@@ -2014,9 +2176,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2541,6 +2705,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.7.6"
@@ -2610,6 +2780,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3027,6 +3214,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3770,16 +4001,21 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3790,10 +4026,11 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower 0.5.2",
"tower-http 0.6.6",
"tower-service",
"url",
"wasm-bindgen",
@@ -4037,6 +4274,15 @@ dependencies = [
"sdd",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -4112,6 +4358,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4206,6 +4475,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -4440,7 +4720,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -4581,6 +4861,27 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4602,7 +4903,7 @@ checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
dependencies = [
"bitflags 2.9.4",
"block2 0.6.1",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -5241,6 +5542,16 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5378,6 +5689,17 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109"
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.2"
@@ -5391,6 +5713,23 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.9.4",
"bytes",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
@@ -5406,7 +5745,7 @@ dependencies = [
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower 0.5.2",
"tower-layer",
"tower-service",
]
@@ -5429,6 +5768,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
+12 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.8.0"
version = "3.8.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -37,12 +37,18 @@ tauri-plugin-deep-link = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3"
async-stream = "0.3"
bytes = "1.5"
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.0", features = ["full"] }
regex = "1.10"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "1.0"
thiserror = "2.0"
anyhow = "1.0"
zip = "2.2"
serde_yaml = "0.9"
@@ -53,6 +59,8 @@ once_cell = "1.21.3"
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "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,
}
}
+6
View File
@@ -6,11 +6,14 @@ mod env;
mod import_export;
mod mcp;
mod misc;
mod model_test;
mod plugin;
mod prompt;
mod provider;
mod proxy;
mod settings;
pub mod skill;
mod usage;
pub use config::*;
pub use deeplink::*;
@@ -18,8 +21,11 @@ pub use env::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use model_test::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
pub use proxy::*;
pub use settings::*;
pub use skill::*;
pub use usage::*;
+128
View File
@@ -0,0 +1,128 @@
//! 模型测试相关命令
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::model_test::{
ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService,
};
use crate::store::AppState;
use tauri::State;
/// 测试单个供应商的模型可用性
#[tauri::command]
pub async fn test_provider_model(
state: State<'_, AppState>,
app_type: AppType,
provider_id: String,
) -> Result<ModelTestResult, AppError> {
// 获取测试配置
let config = state.db.get_model_test_config()?;
// 获取供应商
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
// 执行测试
let result = ModelTestService::test_provider(&app_type, provider, &config).await?;
// 记录日志
let _ = state.db.save_model_test_log(
&provider_id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
Ok(result)
}
/// 批量测试所有供应商
#[tauri::command]
pub async fn test_all_providers_model(
state: State<'_, AppState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, ModelTestResult)>, AppError> {
let config = state.db.get_model_test_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
for (id, provider) in providers {
// 如果只测试代理目标,跳过非代理目标
if proxy_targets_only && !provider.is_proxy_target.unwrap_or(false) {
continue;
}
match ModelTestService::test_provider(&app_type, &provider, &config).await {
Ok(result) => {
// 记录日志
let _ = state.db.save_model_test_log(
&id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
results.push((id, result));
}
Err(e) => {
let error_result = ModelTestResult {
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
};
results.push((id, error_result));
}
}
}
Ok(results)
}
/// 获取模型测试配置
#[tauri::command]
pub fn get_model_test_config(state: State<'_, AppState>) -> Result<ModelTestConfig, AppError> {
state.db.get_model_test_config()
}
/// 保存模型测试配置
#[tauri::command]
pub fn save_model_test_config(
state: State<'_, AppState>,
config: ModelTestConfig,
) -> Result<(), AppError> {
state.db.save_model_test_config(&config)
}
/// 获取模型测试日志
#[tauri::command]
pub fn get_model_test_logs(
state: State<'_, AppState>,
app_type: Option<String>,
provider_id: Option<String>,
limit: Option<u32>,
) -> Result<Vec<ModelTestLog>, AppError> {
state.db.get_model_test_logs(
app_type.as_deref(),
provider_id.as_deref(),
limit.unwrap_or(50),
)
}
/// 清理旧的测试日志
#[tauri::command]
pub fn cleanup_model_test_logs(
state: State<'_, AppState>,
keep_count: Option<u32>,
) -> Result<u64, AppError> {
state.db.cleanup_model_test_logs(keep_count.unwrap_or(100))
}
+13
View File
@@ -86,6 +86,19 @@ pub fn switch_provider(
.map_err(|e| e.to_string())
}
/// 设置代理目标供应商
#[tauri::command]
pub fn set_proxy_target_provider(
state: State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::set_proxy_target(state.inner(), app_type, &id)
.map(|_| true)
.map_err(|e| e.to_string())
}
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
ProviderService::import_default_config(state, app_type)
}
+161
View File
@@ -0,0 +1,161 @@
//! 代理服务相关的 Tauri 命令
//!
//! 提供前端调用的 API 接口
use crate::provider::Provider;
use crate::proxy::types::*;
use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats};
use crate::store::AppState;
/// 启动代理服务器
#[tauri::command]
pub async fn start_proxy_server(
state: tauri::State<'_, AppState>,
) -> Result<ProxyServerInfo, String> {
state.proxy_service.start().await
}
/// 停止代理服务器
#[tauri::command]
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
state.proxy_service.stop().await
}
/// 获取代理服务器状态
#[tauri::command]
pub async fn get_proxy_status(state: tauri::State<'_, AppState>) -> Result<ProxyStatus, String> {
state.proxy_service.get_status().await
}
/// 获取代理配置
#[tauri::command]
pub async fn get_proxy_config(state: tauri::State<'_, AppState>) -> Result<ProxyConfig, String> {
state.proxy_service.get_config().await
}
/// 更新代理配置
#[tauri::command]
pub async fn update_proxy_config(
state: tauri::State<'_, AppState>,
config: ProxyConfig,
) -> Result<(), String> {
state.proxy_service.update_config(&config).await
}
/// 检查代理服务器是否正在运行
#[tauri::command]
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)
}
+178
View File
@@ -0,0 +1,178 @@
//! 使用统计相关命令
use crate::error::AppError;
use crate::services::usage_stats::*;
use crate::store::AppState;
use tauri::State;
/// 获取使用量汇总
#[tauri::command]
pub fn get_usage_summary(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<UsageSummary, AppError> {
state.db.get_usage_summary(start_date, end_date)
}
/// 获取每日趋势
#[tauri::command]
pub fn get_usage_trends(
state: State<'_, AppState>,
days: u32,
) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(days)
}
/// 获取 Provider 统计
#[tauri::command]
pub fn get_provider_stats(state: State<'_, AppState>) -> Result<Vec<ProviderStats>, AppError> {
state.db.get_provider_stats()
}
/// 获取模型统计
#[tauri::command]
pub fn get_model_stats(state: State<'_, AppState>) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats()
}
/// 获取请求日志列表
#[tauri::command]
pub fn get_request_logs(
state: State<'_, AppState>,
filters: LogFilters,
page: u32,
page_size: u32,
) -> Result<PaginatedLogs, AppError> {
state.db.get_request_logs(&filters, page, page_size)
}
/// 获取单个请求详情
#[tauri::command]
pub fn get_request_detail(
state: State<'_, AppState>,
request_id: String,
) -> Result<Option<RequestLogDetail>, AppError> {
state.db.get_request_detail(&request_id)
}
/// 获取模型定价列表
#[tauri::command]
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
log::info!("获取模型定价列表");
state.db.ensure_model_pricing_seeded()?;
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
// 检查表是否存在
let table_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
[],
|row| row.get::<_, i64>(0).map(|count| count > 0),
)
.unwrap_or(false);
if !table_exists {
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
return Ok(Vec::new());
}
let mut stmt = conn.prepare(
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
FROM model_pricing
ORDER BY display_name",
)?;
let rows = stmt.query_map([], |row| {
Ok(ModelPricingInfo {
model_id: row.get(0)?,
display_name: row.get(1)?,
input_cost_per_million: row.get(2)?,
output_cost_per_million: row.get(3)?,
cache_read_cost_per_million: row.get(4)?,
cache_creation_cost_per_million: row.get(5)?,
})
})?;
let mut pricing = Vec::new();
for row in rows {
pricing.push(row?);
}
log::info!("成功获取 {} 条模型定价数据", pricing.len());
Ok(pricing)
}
/// 更新模型定价
#[tauri::command]
pub fn update_model_pricing(
state: State<'_, AppState>,
model_id: String,
display_name: String,
input_cost: String,
output_cost: String,
cache_read_cost: String,
cache_creation_cost: String,
) -> Result<(), AppError> {
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
model_id,
display_name,
input_cost,
output_cost,
cache_read_cost,
cache_creation_cost
],
)
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
Ok(())
}
/// 检查 Provider 使用限额
#[tauri::command]
pub fn check_provider_limits(
state: State<'_, AppState>,
provider_id: String,
app_type: String,
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
state.db.check_provider_limits(&provider_id, &app_type)
}
/// 删除模型定价
#[tauri::command]
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"DELETE FROM model_pricing WHERE model_id = ?1",
rusqlite::params![model_id],
)
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
log::info!("已删除模型定价: {model_id}");
Ok(())
}
/// 模型定价信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelPricingInfo {
pub model_id: String,
pub display_name: String,
pub input_cost_per_million: String,
pub output_cost_per_million: String,
pub cache_read_cost_per_million: String,
pub cache_creation_cost_per_million: String,
}
+8 -7
View File
@@ -1,11 +1,12 @@
//! 数据访问对象 (DAO) 模块
//! Data Access Object layer
//!
//! 提供各类数据的 CRUD 操作。
//! Database access operations for each domain
mod mcp;
mod prompts;
mod providers;
mod settings;
mod skills;
pub mod mcp;
pub mod prompts;
pub mod providers;
pub mod proxy;
pub mod settings;
pub mod skills;
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
+237 -10
View File
@@ -17,7 +17,7 @@ impl Database {
) -> 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
"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
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,6 +35,7 @@ impl Database {
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);
@@ -54,6 +55,7 @@ impl Database {
meta: Some(meta),
icon,
icon_color,
is_proxy_target: Some(is_proxy_target),
},
))
})
@@ -121,6 +123,77 @@ impl Database {
}
}
/// 根据 ID 获取单个供应商
pub fn get_provider_by_id(
&self,
id: &str,
app_type: &str,
) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let result = conn.query_row(
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, is_proxy_target
FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
|row| {
let name: String = row.get(0)?;
let settings_config_str: String = row.get(1)?;
let website_url: Option<String> = row.get(2)?;
let category: Option<String> = row.get(3)?;
let created_at: Option<i64> = row.get(4)?;
let sort_index: Option<usize> = row.get(5)?;
let notes: Option<String> = row.get(6)?;
let icon: Option<String> = row.get(7)?;
let icon_color: Option<String> = row.get(8)?;
let meta_str: String = row.get(9)?;
let is_proxy_target: bool = row.get(10)?;
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(Provider {
id: 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),
})
},
);
match result {
Ok(provider) => Ok(Some(provider)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 获取代理目标供应商 ID
pub fn get_proxy_target_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_proxy_target = 1 LIMIT 1")
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query(params![app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
if let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
Ok(Some(
row.get(0).map_err(|e| AppError::Database(e.to_string()))?,
))
} else {
Ok(None)
}
}
/// 保存供应商(新增或更新)
///
/// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理
@@ -135,17 +208,17 @@ impl Database {
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<bool> = tx
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 is_proxy_target
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
"SELECT is_current, is_proxy_target FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
let (is_current, is_proxy_target) = existing.unwrap_or((false, false));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
@@ -161,8 +234,9 @@ impl Database {
icon = ?8,
icon_color = ?9,
meta = ?10,
is_current = ?11
WHERE id = ?12 AND app_type = ?13",
is_current = ?11,
is_proxy_target = ?12
WHERE id = ?13 AND app_type = ?14",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
@@ -175,6 +249,7 @@ impl Database {
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
is_proxy_target,
provider.id,
app_type,
],
@@ -185,8 +260,8 @@ impl Database {
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
created_at, sort_index, notes, icon, icon_color, meta, is_current, is_proxy_target
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
provider.id,
app_type,
@@ -201,6 +276,7 @@ impl Database {
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
is_proxy_target,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -256,6 +332,157 @@ impl Database {
Ok(())
}
/// 设置代理目标供应商
pub fn set_proxy_target_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 重置所有为 0
tx.execute(
"UPDATE providers SET is_proxy_target = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 设置新的代理目标供应商
tx.execute(
"UPDATE providers SET is_proxy_target = 1 WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
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);
let mut stmt = conn
.prepare("SELECT app_type, name, id FROM providers WHERE is_proxy_target = 1")
.map_err(|e| AppError::Database(e.to_string()))?;
let targets = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(targets)
}
/// 添加自定义端点
pub fn add_custom_endpoint(
&self,
+321
View File
@@ -0,0 +1,321 @@
//! 代理功能数据访问层
//!
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
use crate::error::AppError;
use crate::proxy::types::*;
use super::super::{lock_conn, Database};
impl Database {
// ==================== Proxy Config ====================
/// 获取代理配置
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
// 在一个作用域内获取锁并查询,确保锁在await之前释放
let result = {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT enabled, listen_address, listen_port, max_retries,
request_timeout, enable_logging
FROM proxy_config WHERE id = 1",
[],
|row| {
Ok(ProxyConfig {
enabled: row.get::<_, i32>(0)? != 0,
listen_address: row.get(1)?,
listen_port: row.get::<_, i32>(2)? as u16,
max_retries: row.get::<_, i32>(3)? as u8,
request_timeout: row.get::<_, i32>(4)? as u64,
enable_logging: row.get::<_, i32>(5)? != 0,
})
},
)
}; // conn锁在这里释放
match result {
Ok(config) => Ok(config),
Err(rusqlite::Error::QueryReturnedNoRows) => {
// 如果不存在,插入默认配置
let default_config = ProxyConfig::default();
self.update_proxy_config(default_config.clone()).await?;
Ok(default_config)
}
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 更新代理配置
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO proxy_config
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, target_app, created_at, updated_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7,
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
datetime('now'))",
rusqlite::params![
if config.enabled { 1 } else { 0 },
config.listen_address,
config.listen_port as i32,
config.max_retries as i32,
config.request_timeout as i32,
if config.enable_logging { 1 } else { 0 },
"claude", // 兼容旧字段,写入默认值
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// ==================== Provider Health ====================
/// 获取Provider健康状态
pub async fn get_provider_health(
&self,
provider_id: &str,
app_type: &str,
) -> Result<ProviderHealth, AppError> {
let conn = lock_conn!(self.conn);
conn.query_row(
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at
FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| {
Ok(ProviderHealth {
provider_id: row.get(0)?,
app_type: row.get(1)?,
is_healthy: row.get::<_, i64>(2)? != 0,
consecutive_failures: row.get::<_, i64>(3)? as u32,
last_success_at: row.get(4)?,
last_failure_at: row.get(5)?,
last_error: row.get(6)?,
updated_at: row.get(7)?,
})
},
)
.map_err(|e| AppError::Database(e.to_string()))
}
/// 更新Provider健康状态
pub async fn update_provider_health(
&self,
provider_id: &str,
app_type: &str,
success: bool,
error_msg: Option<String>,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let now = chrono::Utc::now().to_rfc3339();
// 先查询当前状态
let current = conn.query_row(
"SELECT consecutive_failures FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| Ok(row.get::<_, i64>(0)? as u32),
);
let (is_healthy, consecutive_failures) = if success {
// 成功:重置失败计数
(1, 0)
} else {
// 失败:增加失败计数
let failures = current.unwrap_or(0) + 1;
let healthy = if failures >= 3 { 0 } else { 1 };
(healthy, failures)
};
let (last_success_at, last_failure_at) = if success {
(Some(now.clone()), None)
} else {
(None, Some(now.clone()))
};
// UPSERT
conn.execute(
"INSERT OR REPLACE INTO provider_health
(provider_id, app_type, is_healthy, consecutive_failures,
last_success_at, last_failure_at, last_error, updated_at)
VALUES (?1, ?2, ?3, ?4,
COALESCE(?5, (SELECT last_success_at FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2)),
COALESCE(?6, (SELECT last_failure_at FROM provider_health
WHERE provider_id = ?1 AND app_type = ?2)),
?7, ?8)",
rusqlite::params![
provider_id,
app_type,
is_healthy,
consecutive_failures as i64,
last_success_at,
last_failure_at,
error_msg,
&now,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
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 (可选) ====================
/// 记录代理使用统计
#[allow(dead_code)]
pub async fn record_proxy_usage(&self, record: &ProxyUsageRecord) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT INTO proxy_usage
(provider_id, app_type, endpoint, request_tokens, response_tokens,
status_code, latency_ms, error, timestamp)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
&record.provider_id,
&record.app_type,
&record.endpoint,
record.request_tokens,
record.response_tokens,
record.status_code as i64,
record.latency_ms as i64,
&record.error,
&record.timestamp,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 查询最近的使用统计
#[allow(dead_code)]
pub async fn get_recent_usage(
&self,
provider_id: &str,
app_type: &str,
limit: usize,
) -> Result<Vec<ProxyUsageRecord>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT provider_id, app_type, endpoint, request_tokens, response_tokens,
status_code, latency_ms, error, timestamp
FROM proxy_usage
WHERE provider_id = ?1 AND app_type = ?2
ORDER BY timestamp DESC
LIMIT ?3",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let rows = stmt
.query_map(
rusqlite::params![provider_id, app_type, limit as i64],
|row| {
Ok(ProxyUsageRecord {
provider_id: row.get(0)?,
app_type: row.get(1)?,
endpoint: row.get(2)?,
request_tokens: row.get(3)?,
response_tokens: row.get(4)?,
status_code: row.get::<_, i64>(5)? as u16,
latency_ms: row.get::<_, i64>(6)? as u64,
error: row.get(7)?,
timestamp: row.get(8)?,
})
},
)
.map_err(|e| AppError::Database(e.to_string()))?;
let mut records = Vec::new();
for row in rows {
records.push(row.map_err(|e| AppError::Database(e.to_string()))?);
}
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(())
}
}
+3 -1
View File
@@ -44,7 +44,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 1;
pub(crate) const SCHEMA_VERSION: i32 = 2;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
@@ -95,6 +95,7 @@ impl Database {
};
db.create_tables()?;
db.apply_schema_migrations()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
}
@@ -111,6 +112,7 @@ impl Database {
conn: Mutex::new(conn),
};
db.create_tables()?;
db.ensure_model_pricing_seeded()?;
Ok(db)
}
+488 -1
View File
@@ -31,12 +31,19 @@ impl Database {
icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0,
is_proxy_target BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 is_proxy_target 列(如果表已存在但缺少该列)
let _ = conn.execute(
"ALTER TABLE providers ADD COLUMN is_proxy_target BOOLEAN NOT NULL DEFAULT 0",
[],
);
// 2. Provider Endpoints 表
conn.execute(
"CREATE TABLE IF NOT EXISTS provider_endpoints (
@@ -120,6 +127,237 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 8. Proxy Config 表 (代理服务器配置)
// 代理配置表(单例)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000,
max_retries INTEGER NOT NULL DEFAULT 3,
request_timeout INTEGER NOT NULL DEFAULT 300,
enable_logging INTEGER NOT NULL DEFAULT 1,
target_app TEXT NOT NULL DEFAULT 'claude',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 尝试添加 target_app 列(如果表已存在但缺少该列)
// 忽略 "duplicate column name" 错误
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
[],
);
// 9. Provider Health 表 (Provider健康状态)
conn.execute(
"CREATE TABLE IF NOT EXISTS provider_health (
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
is_healthy INTEGER NOT NULL DEFAULT 1,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_success_at TEXT,
last_failure_at TEXT,
last_error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (provider_id, app_type),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 10. Proxy Usage 表 (代理使用统计,可选)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_tokens INTEGER,
response_tokens INTEGER,
status_code INTEGER NOT NULL,
latency_ms INTEGER NOT NULL,
error TEXT,
timestamp TEXT NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 为 proxy_usage 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_timestamp
ON proxy_usage(timestamp)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_proxy_usage_provider
ON proxy_usage(provider_id, app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 11. Proxy Request Logs 表 (详细请求日志)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
ON proxy_request_logs(provider_id, app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
ON proxy_request_logs(created_at)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
ON proxy_request_logs(model)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
ON proxy_request_logs(session_id)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
ON proxy_request_logs(status_code)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 12. Model Pricing 表 (模型定价)
conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 13. Usage Daily Stats 表 (每日聚合统计)
conn.execute(
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
date TEXT NOT NULL,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
total_input_tokens INTEGER NOT NULL DEFAULT 0,
total_output_tokens INTEGER NOT NULL DEFAULT 0,
total_cost_usd TEXT NOT NULL DEFAULT '0',
success_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, provider_id, app_type, model)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 14. Model Test Logs 表 (模型测试日志,独立于代理使用统计)
conn.execute(
"CREATE TABLE IF NOT EXISTS model_test_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
provider_name TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
prompt TEXT NOT NULL,
success INTEGER NOT NULL,
message TEXT NOT NULL,
response_time_ms INTEGER,
http_status INTEGER,
tested_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_provider
ON model_test_logs(provider_id, app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_tested_at
ON model_test_logs(tested_at DESC)",
[],
)
.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(())
}
@@ -150,7 +388,12 @@ impl Database {
0 => {
log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
Self::migrate_v0_to_v1(conn)?;
Self::set_user_version(conn, SCHEMA_VERSION)?;
Self::set_user_version(conn, 1)?;
}
1 => {
log::info!("迁移数据库从 v1 到 v2(添加使用统计表和完整字段)");
Self::migrate_v1_to_v2(conn)?;
Self::set_user_version(conn, 2)?;
}
_ => {
return Err(AppError::Database(format!(
@@ -237,6 +480,250 @@ impl Database {
Ok(())
}
/// v1 -> v2 迁移:添加使用统计表和完整字段
fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
// providers 表字段
Self::add_column_if_missing(
conn,
"providers",
"cost_multiplier",
"TEXT NOT NULL DEFAULT '1.0'",
)?;
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
// proxy_request_logs 表(包含所有字段)
conn.execute(
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
request_id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
input_cost_usd TEXT NOT NULL DEFAULT '0',
output_cost_usd TEXT NOT NULL DEFAULT '0',
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
total_cost_usd TEXT NOT NULL DEFAULT '0',
latency_ms INTEGER NOT NULL,
first_token_ms INTEGER,
duration_ms INTEGER,
status_code INTEGER NOT NULL,
error_message TEXT,
session_id TEXT,
provider_type TEXT,
is_streaming INTEGER NOT NULL DEFAULT 0,
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
created_at INTEGER NOT NULL
)",
[],
)?;
// 为已存在的表添加新字段
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
Self::add_column_if_missing(
conn,
"proxy_request_logs",
"is_streaming",
"INTEGER NOT NULL DEFAULT 0",
)?;
Self::add_column_if_missing(
conn,
"proxy_request_logs",
"cost_multiplier",
"TEXT NOT NULL DEFAULT '1.0'",
)?;
Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
// model_pricing 表
conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL,
output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
[],
)?;
// usage_daily_stats 表
conn.execute(
"CREATE TABLE IF NOT EXISTS usage_daily_stats (
date TEXT NOT NULL,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
total_input_tokens INTEGER NOT NULL DEFAULT 0,
total_output_tokens INTEGER NOT NULL DEFAULT 0,
total_cost_usd TEXT NOT NULL DEFAULT '0',
success_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (date, provider_id, app_type, model)
)",
[],
)?;
// 清空并重新插入模型定价
conn.execute("DELETE FROM model_pricing", [])
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
Self::seed_model_pricing(conn)?;
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.5 系列
(
"claude-opus-4-5",
"Claude Opus 4.5",
"5",
"25",
"0.50",
"6.25",
),
(
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
"3",
"15",
"0.30",
"3.75",
),
(
"claude-haiku-4-5",
"Claude Haiku 4.5",
"1",
"5",
"0.10",
"1.25",
),
// Claude 4.1 系列
(
"claude-opus-4-1",
"Claude Opus 4.1",
"15",
"75",
"1.50",
"18.75",
),
(
"claude-sonnet-4-1",
"Claude Sonnet 4.1",
"3",
"15",
"0.30",
"3.75",
),
// Claude 3.7 系列
(
"claude-sonnet-3-7",
"Claude Sonnet 3.7",
"3",
"15",
"0.30",
"3.75",
),
// Claude 3.5 系列
(
"claude-sonnet-3-5",
"Claude Sonnet 3.5",
"3",
"15",
"0.30",
"3.75",
),
(
"claude-haiku-3-5",
"Claude Haiku 3.5",
"0.80",
"4",
"0.08",
"1",
),
// GPT-5 系列(model_id 使用短横线格式)
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
// Gemini 3 系列
(
"gemini-3-pro-preview",
"Gemini 3 Pro Preview",
"2",
"12",
"0",
"0",
),
// Gemini 2.5 系列(model_id 使用短横线格式)
(
"gemini-2-5-pro",
"Gemini 2.5 Pro",
"1.25",
"10",
"0.125",
"0",
),
(
"gemini-2-5-flash",
"Gemini 2.5 Flash",
"0.3",
"2.5",
"0.03",
"0",
),
];
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
model_id,
display_name,
input,
output,
cache_read,
cache_creation
],
)
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
}
log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
Ok(())
}
/// 确保模型定价表具备默认数据
pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
Self::ensure_model_pricing_seeded_on_conn(&conn)
}
fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.map_err(|e| AppError::Database(format!("统计模型定价数据失败: {e}")))?;
if count == 0 {
Self::seed_model_pricing(conn)?;
}
Ok(())
}
// --- 辅助方法 ---
pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
+60
View File
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
meta: None,
icon: None,
icon_color: None,
is_proxy_target: Some(false),
},
);
@@ -272,3 +273,62 @@ fn dry_run_validates_schema_compatibility() {
"Dry-run should succeed with provider data: {result:?}"
);
}
#[test]
fn model_pricing_is_seeded_on_init() {
let db = Database::memory().expect("create memory db");
let conn = db.conn.lock().expect("lock conn");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM model_pricing", [], |row| row.get(0))
.expect("count pricing");
assert!(
count > 0,
"模型定价数据应该在初始化时自动填充,实际数量: {}",
count
);
// 验证包含 Claude 模型
let claude_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'claude-%'",
[],
|row| row.get(0),
)
.expect("check claude");
assert!(
claude_count > 0,
"应该包含 Claude 模型定价,实际数量: {}",
claude_count
);
// 验证包含 GPT 模型
let gpt_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gpt-%'",
[],
|row| row.get(0),
)
.expect("check gpt");
assert!(
gpt_count > 0,
"应该包含 GPT 模型定价,实际数量: {}",
gpt_count
);
// 验证包含 Gemini 模型
let gemini_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id LIKE 'gemini-%'",
[],
|row| row.get(0),
)
.expect("check gemini");
assert!(
gemini_count > 0,
"应该包含 Gemini 模型定价,实际数量: {}",
gemini_count
);
}
+1
View File
@@ -129,6 +129,7 @@ pub(crate) fn build_provider_from_request(
meta: None,
icon: request.icon.clone(),
icon_color: None,
is_proxy_target: None,
};
Ok(provider)
+15
View File
@@ -91,12 +91,27 @@ impl<T> From<PoisonError<T>> for AppError {
}
}
impl From<rusqlite::Error> for AppError {
fn from(err: rusqlite::Error) -> Self {
Self::Database(err.to_string())
}
}
impl From<AppError> for String {
fn from(err: AppError) -> Self {
err.to_string()
}
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
-10
View File
@@ -38,16 +38,6 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
atomic_write(path, json.as_bytes())
}
/// 读取 Gemini MCP 配置文件的完整 JSON 文本
pub fn read_mcp_json() -> Result<Option<String>, AppError> {
let path = user_config_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
Ok(Some(content))
}
/// 读取 Gemini settings.json 中的 mcpServers 映射
///
/// 执行反向格式转换以保持与统一 MCP 结构的兼容性:
+67 -297
View File
@@ -9,7 +9,7 @@ mod config;
mod database;
mod deeplink;
mod error;
mod gemini_config; // 新增
mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
@@ -17,9 +17,11 @@ mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod settings;
mod store;
mod tray;
mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
@@ -46,263 +48,11 @@ use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use tauri::{
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
tray::{TrayIconBuilder, TrayIconEvent},
};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
#[cfg(target_os = "macos")]
use tauri::{ActivationPolicy, RunEvent};
use tauri::RunEvent;
use tauri::{Emitter, Manager};
#[derive(Clone, Copy)]
struct TrayTexts {
show_main: &'static str,
no_provider_hint: &'static str,
quit: &'static str,
}
impl TrayTexts {
fn from_language(language: &str) -> Self {
match language {
"en" => Self {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
},
}
}
}
struct TrayAppSection {
app_type: AppType,
prefix: &'static str,
header_id: &'static str,
empty_id: &'static str,
header_label: &'static str,
log_name: &'static str,
}
const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "─── Claude ───",
log_name: "Claude",
},
TrayAppSection {
app_type: AppType::Codex,
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "─── Codex ───",
log_name: "Codex",
},
TrayAppSection {
app_type: AppType::Gemini,
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "─── Gemini ───",
log_name: "Gemini",
},
];
fn append_provider_section<'a>(
app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
};
let header = MenuItem::with_id(
app,
section.header_id,
section.header_label,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&header);
if manager.providers.is_empty() {
let empty_hint = MenuItem::with_id(
app,
section.empty_id,
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
return Ok(menu_builder.item(&empty_hint));
}
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&item);
}
Ok(menu_builder)
}
fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
let app_handle = app.clone();
let provider_id = provider_id.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
return true;
}
}
false
}
/// 创建动态托盘菜单
fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, AppError> {
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
let show_main_item =
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
for section in TRAY_SECTIONS.iter() {
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
current: current_id,
};
menu_builder =
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
#[cfg(target_os = "macos")]
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {err}");
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {err}");
}
}
/// 处理托盘菜单事件
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {event_id}");
match event_id {
"show_main" => {
if let Some(window) = app.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
}
_ => {
if handle_provider_tray_event(app, event_id) {
return;
}
log::warn!("未处理的菜单事件: {event_id}");
}
}
}
/// 统一处理 ccswitch:// 深链接 URL
///
/// - 解析 URL
@@ -362,50 +112,13 @@ fn handle_deeplink_url(
true
}
//
/// 内部切换供应商函数
fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: crate::app_config::AppType,
provider_id: String,
) -> Result<(), AppError> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
.map_err(AppError::Message)?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {e}");
}
}
}
// 发射事件到前端,通知供应商已切换
let event_data = serde_json::json!({
"appType": app_type_str,
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {e}");
}
}
Ok(())
}
/// 更新托盘菜单的Tauri命令
#[tauri::command]
async fn update_tray_menu(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<bool, String> {
match create_tray_menu(&app, state.inner()) {
match tray::create_tray_menu(&app, state.inner()) {
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
@@ -473,7 +186,7 @@ pub fn run() {
}
#[cfg(target_os = "macos")]
{
apply_tray_policy(window.app_handle(), false);
tray::apply_tray_policy(window.app_handle(), false);
}
} else {
window.app_handle().exit(0);
@@ -773,7 +486,7 @@ pub fn run() {
log::info!("✓ Deep-link URL handler registered");
// 创建动态托盘菜单
let menu = create_tray_menu(app.handle(), &app_state)?;
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id("main")
@@ -784,7 +497,7 @@ pub fn run() {
})
.menu(&menu)
.on_menu_event(|app, event| {
handle_tray_menu_event(app, &event.id.0);
tray::handle_tray_menu_event(app, &event.id.0);
})
.show_menu_on_left_click(true);
@@ -809,6 +522,28 @@ pub fn run() {
}
}
// 自动启动代理服务器
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let state = app_handle.state::<AppState>();
match state.db.get_proxy_config().await {
Ok(config) => {
if config.enabled {
log::info!("代理服务配置为启用,正在启动...");
match state.proxy_service.start().await {
Ok(info) => log::info!(
"代理服务器自动启动成功: {}:{}",
info.address,
info.port
),
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
}
}
}
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -818,6 +553,7 @@ pub fn run() {
commands::update_provider,
commands::delete_provider,
commands::switch_provider,
commands::set_proxy_target_provider,
commands::import_default_config,
commands::get_claude_config_status,
commands::get_config_status,
@@ -907,6 +643,40 @@ pub fn run() {
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_server,
commands::stop_proxy_server,
commands::get_proxy_status,
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,
commands::get_provider_stats,
commands::get_model_stats,
commands::get_request_logs,
commands::get_request_detail,
commands::get_model_pricing,
commands::update_model_pricing,
commands::delete_model_pricing,
commands::check_provider_limits,
// Model testing
commands::test_provider_model,
commands::test_all_providers_model,
commands::get_model_test_config,
commands::save_model_test_config,
commands::get_model_test_logs,
commands::cleanup_model_test_logs,
commands::get_tool_versions,
]);
let app = builder
@@ -927,7 +697,7 @@ pub fn run() {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
apply_tray_policy(app_handle, true);
tray::apply_tray_policy(app_handle, true);
}
}
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
+131
View File
@@ -0,0 +1,131 @@
//! Claude MCP 同步和导入模块
use serde_json::Value;
use std::collections::HashMap;
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
}
}
}
out
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let text_opt = crate::claude_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(0);
};
// 确保新结构存在
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Claude 应用
if !existing.apps.claude {
existing.apps.claude = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
}
} else {
// 新建服务器:默认仅启用 Claude
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
}
Ok(changed)
}
/// 将单个 MCP 服务器同步到 Claude live 配置
pub fn sync_single_server_to_claude(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 写回
crate::claude_mcp::set_mcp_servers_map(&updated)
}
/// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::claude_mcp::set_mcp_servers_map(&current)
}
@@ -1,201 +1,17 @@
//! Codex MCP 同步和导入模块
//!
//! 包含 Codex 的 MCP 配置管理:
//! - 从 ~/.codex/config.toml 导入
//! - 同步到 ~/.codex/config.toml
//! - JSON 到 TOML 的转换逻辑
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
use crate::error::AppError;
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
if !spec.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器连接定义必须为 JSON 对象".into(),
));
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
if !(is_stdio || is_http || is_sse) {
return Err(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio".into(),
));
}
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.trim().is_empty() {
return Err(AppError::McpValidation(
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
));
}
}
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
if is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"sse 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
Ok(())
}
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
let obj = entry
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
validate_server_spec(server)?;
for key in ["name", "description", "homepage", "docs"] {
if let Some(val) = obj.get(key) {
if !val.is_string() {
return Err(AppError::McpValidation(format!(
"MCP 服务器 {key} 必须为字符串"
)));
}
}
}
if let Some(tags) = obj.get("tags") {
let arr = tags
.as_array()
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
if !arr.iter().all(|item| item.is_string()) {
return Err(AppError::McpValidation(
"MCP 服务器 tags 必须为字符串数组".into(),
));
}
}
if let Some(enabled) = obj.get("enabled") {
if !enabled.is_boolean() {
return Err(AppError::McpValidation(
"MCP 服务器 enabled 必须为布尔值".into(),
));
}
}
Ok(())
}
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
let mut change_count = 0usize;
let mut renames: Vec<(String, String)> = Vec::new();
for (key_ref, value) in map.iter_mut() {
let key = key_ref.clone();
let Some(obj) = value.as_object_mut() else {
continue;
};
let id_value = obj.get("id").cloned();
let target_id: String;
match id_value {
Some(id_val) => match id_val.as_str() {
Some(id_str) => {
let trimmed = id_str.trim();
if trimmed.is_empty() {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
} else {
if trimmed != id_str {
obj.insert("id".into(), json!(trimmed));
change_count += 1;
}
target_id = trimmed.to_string();
}
}
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
},
None => {
obj.insert("id".into(), json!(key.clone()));
change_count += 1;
target_id = key.clone();
}
}
if target_id != key {
renames.push((key, target_id));
}
}
for (old_key, new_key) in renames {
if old_key == new_key {
continue;
}
if map.contains_key(&new_key) {
log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
if let Some(value) = map.get_mut(&old_key) {
if let Some(obj) = value.as_object_mut() {
if obj
.get("id")
.and_then(|v| v.as_str())
.map(|s| s != old_key)
.unwrap_or(true)
{
obj.insert("id".into(), json!(old_key.clone()));
change_count += 1;
}
}
}
continue;
}
if let Some(mut value) = map.remove(&old_key) {
if let Some(obj) = value.as_object_mut() {
obj.insert("id".into(), json!(new_key.clone()));
}
log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'");
map.insert(new_key, value);
change_count += 1;
}
}
change_count
}
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
let servers = &mut config.mcp_for_mut(app).servers;
normalize_server_keys(servers)
}
fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
let obj = entry
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
if !server.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器 server 字段必须为 JSON 对象".into(),
));
}
Ok(server.clone())
}
use super::validation::{extract_server_spec, validate_server_spec};
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
@@ -220,185 +36,6 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
out
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn get_servers_snapshot_for(
config: &mut MultiAppConfig,
app: &AppType,
) -> (HashMap<String, Value>, usize) {
let normalized = normalize_servers_for(config, app);
let mut snapshot = config.mcp_for(app).servers.clone();
snapshot.retain(|id, value| {
let Some(obj) = value.as_object_mut() else {
log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
return false;
};
obj.entry(String::from("id")).or_insert(json!(id));
match validate_mcp_entry(value) {
Ok(()) => true,
Err(err) => {
log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
false
}
}
});
(snapshot, normalized)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn upsert_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
spec: Value,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
validate_mcp_entry(&spec)?;
let mut entry_obj = spec
.as_object()
.cloned()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
if let Some(existing_id) = entry_obj.get("id") {
let Some(existing_id_str) = existing_id.as_str() else {
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
};
if existing_id_str != id {
return Err(AppError::McpValidation(format!(
"MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
)));
}
} else {
entry_obj.insert(String::from("id"), json!(id));
}
let value = Value::Object(entry_obj);
let servers = &mut config.mcp_for_mut(app).servers;
let before = servers.get(id).cloned();
servers.insert(id.to_string(), value);
Ok(before.is_none())
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn delete_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
Ok(existed)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
/// 设置启用状态(不执行落盘或文件同步)
pub fn set_enabled_flag_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
// 写入 enabled 字段
let mut obj = spec
.as_object()
.cloned()
.ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
obj.insert("enabled".into(), json!(enabled));
*spec = Value::Object(obj);
} else {
// 若不存在则直接返回 false
return Ok(false);
}
Ok(true)
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text_opt = crate::claude_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(0);
};
// 确保新结构存在
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Claude 应用
if !existing.apps.claude {
existing.apps.claude = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
}
} else {
// 新建服务器:默认仅启用 Claude
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
}
Ok(changed)
}
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+
///
/// 格式支持:
@@ -407,8 +44,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
///
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text = crate::codex_config::read_and_validate_codex_config_text()?;
if text.trim().is_empty() {
return Ok(0);
@@ -697,111 +332,95 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
Ok(())
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.gemini/settings.json 导入 mcpServers 到统一结构(v3.7.0+
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text_opt = crate::gemini_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.gemini/settings.json 失败: {e}")))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(0);
};
// 确保新结构存在
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Gemini 应用
if !existing.apps.gemini {
existing.apps.gemini = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
}
} else {
// 新建服务器:默认仅启用 Gemini
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: false,
codex: false,
gemini: true,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
}
Ok(changed)
}
// ============================================================================
// v3.7.0 新增:单个服务器同步和删除函数
// ============================================================================
/// 将单个 MCP 服务器同步到 Claude live 配置
pub fn sync_single_server_to_claude(
/// 将单个 MCP 服务器同步到 Codex live 配置
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
pub fn sync_single_server_to_codex(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
use toml_edit::Item;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 读取现有的 config.toml
let config_path = crate::codex_config::get_codex_config_path();
// 写回
crate::claude_mcp::set_mcp_servers_map(&updated)
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
} else {
toml_edit::DocumentMut::new()
};
// 清理可能存在的错误格式 [mcp.servers]
if let Some(mcp_item) = doc.get_mut("mcp") {
if let Some(tbl) = mcp_item.as_table_like_mut() {
if tbl.contains_key("servers") {
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
tbl.remove("servers");
}
}
}
// 确保 [mcp_servers] 表存在
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
// 将 JSON 服务器规范转换为 TOML 表
let toml_table = json_server_to_toml_table(server_spec)?;
// 使用唯一正确的格式:[mcp_servers]
doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
/// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let config_path = crate::codex_config::get_codex_config_path();
// 移除指定服务器
current.remove(id);
if !config_path.exists() {
return Ok(()); // 文件不存在,无需删除
}
// 写回
crate::claude_mcp::set_mcp_servers_map(&current)
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
mcp_servers.remove(id);
}
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
// ============================================================================
// TOML 转换辅助函数
// ============================================================================
/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
///
/// 支持的类型转换:
@@ -1030,117 +649,3 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
Ok(t)
}
/// 将单个 MCP 服务器同步到 Codex live 配置
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
pub fn sync_single_server_to_codex(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
use toml_edit::Item;
// 读取现有的 config.toml
let config_path = crate::codex_config::get_codex_config_path();
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
} else {
toml_edit::DocumentMut::new()
};
// 清理可能存在的错误格式 [mcp.servers]
if let Some(mcp_item) = doc.get_mut("mcp") {
if let Some(tbl) = mcp_item.as_table_like_mut() {
if tbl.contains_key("servers") {
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
tbl.remove("servers");
}
}
}
// 确保 [mcp_servers] 表存在
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
// 将 JSON 服务器规范转换为 TOML 表
let toml_table = json_server_to_toml_table(server_spec)?;
// 使用唯一正确的格式:[mcp_servers]
doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() {
return Ok(()); // 文件不存在,无需删除
}
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
mcp_servers.remove(id);
}
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 将单个 MCP 服务器同步到 Gemini live 配置
pub fn sync_single_server_to_gemini(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::gemini_mcp::read_mcp_servers_map()?;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 写回
crate::gemini_mcp::set_mcp_servers_map(&updated)
}
/// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::gemini_mcp::set_mcp_servers_map(&current)
}
+126
View File
@@ -0,0 +1,126 @@
//! Gemini MCP 同步和导入模块
use serde_json::Value;
use std::collections::HashMap;
use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig};
use crate::error::AppError;
use super::validation::{extract_server_spec, validate_server_spec};
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
}
}
}
out
}
/// 将 config.json 中 Gemini 的 enabled==true 项写入 Gemini MCP 配置
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}
/// 从 Gemini MCP 配置导入到统一结构(v3.7.0+)
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let map = crate::gemini_mcp::read_mcp_servers_map()?;
if map.is_empty() {
return Ok(0);
}
// 确保新结构存在
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Gemini 应用
if !existing.apps.gemini {
existing.apps.gemini = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
}
} else {
// 新建服务器:默认仅启用 Gemini
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: false,
codex: false,
gemini: true,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
}
Ok(changed)
}
/// 将单个 MCP 服务器同步到 Gemini live 配置
pub fn sync_single_server_to_gemini(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
// 添加/更新当前服务器
current.insert(id.to_string(), server_spec.clone());
// 写回
crate::gemini_mcp::set_mcp_servers_map(&current)
}
/// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::gemini_mcp::set_mcp_servers_map(&current)
}
+28
View File
@@ -0,0 +1,28 @@
//! MCP (Model Context Protocol) 服务器管理模块
//!
//! 本模块负责 MCP 服务器配置的验证、同步和导入导出。
//!
//! ## 模块结构
//!
//! - `validation` - 服务器配置验证
//! - `claude` - Claude MCP 同步和导入
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
mod claude;
mod codex;
mod gemini;
mod validation;
// 重新导出公共 API
pub use claude::{
import_from_claude, remove_server_from_claude, sync_enabled_to_claude,
sync_single_server_to_claude,
};
pub use codex::{
import_from_codex, remove_server_from_codex, sync_enabled_to_codex, sync_single_server_to_codex,
};
pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
+69
View File
@@ -0,0 +1,69 @@
//! MCP 服务器配置验证模块
use serde_json::Value;
use crate::error::AppError;
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
pub fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
if !spec.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器连接定义必须为 JSON 对象".into(),
));
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
if !(is_stdio || is_http || is_sse) {
return Err(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio".into(),
));
}
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.trim().is_empty() {
return Err(AppError::McpValidation(
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
));
}
}
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
if is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"sse 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
Ok(())
}
/// 从 MCP 条目中提取服务器规范
pub fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
let obj = entry
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
if !server.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器 server 字段必须为 JSON 对象".into(),
));
}
Ok(server.clone())
}
+14
View File
@@ -36,6 +36,10 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 是否为代理目标(数据库专用字段,不写入配置文件)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "isProxyTarget")]
pub is_proxy_target: Option<bool>,
}
impl Provider {
@@ -58,6 +62,7 @@ impl Provider {
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
}
@@ -151,6 +156,15 @@ pub struct ProviderMeta {
skip_serializing_if = "Option::is_none"
)]
pub partner_promotion_key: Option<String>,
/// 成本倍数(用于计算实际成本)
#[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")]
pub cost_multiplier: Option<String>,
/// 每日消费限额(USD
#[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")]
pub limit_daily_usd: Option<String>,
/// 每月消费限额(USD
#[serde(rename = "limitMonthlyUsd", skip_serializing_if = "Option::is_none")]
pub limit_monthly_usd: Option<String>,
}
impl ProviderManager {
+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);
}
}
+183
View File
@@ -0,0 +1,183 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProxyError {
#[error("服务器已在运行")]
AlreadyRunning,
#[error("服务器未运行")]
NotRunning,
#[error("地址绑定失败: {0}")]
BindFailed(String),
#[error("请求转发失败: {0}")]
ForwardFailed(String),
#[error("无可用的Provider")]
NoAvailableProvider,
#[allow(dead_code)]
#[error("Provider不健康: {0}")]
ProviderUnhealthy(String),
#[error("上游错误 (状态码 {status}): {body:?}")]
UpstreamError { status: u16, body: Option<String> },
#[error("超过最大重试次数")]
MaxRetriesExceeded,
#[error("数据库错误: {0}")]
DatabaseError(String),
#[error("配置错误: {0}")]
ConfigError(String),
#[allow(dead_code)]
#[error("格式转换错误: {0}")]
TransformError(String),
#[allow(dead_code)]
#[error("无效的请求: {0}")]
InvalidRequest(String),
#[error("超时: {0}")]
Timeout(String),
/// 流式响应空闲超时
#[allow(dead_code)]
#[error("流式响应空闲超时: {0}秒无数据")]
StreamIdleTimeout(u64),
/// 认证错误
#[allow(dead_code)]
#[error("认证失败: {0}")]
AuthError(String),
#[allow(dead_code)]
#[error("内部错误: {0}")]
Internal(String),
}
impl IntoResponse for ProxyError {
fn into_response(self) -> Response {
let (status, body) = match &self {
ProxyError::UpstreamError {
status: upstream_status,
body: upstream_body,
} => {
let http_status =
StatusCode::from_u16(*upstream_status).unwrap_or(StatusCode::BAD_GATEWAY);
// 尝试解析上游响应体为 JSON,如果失败则包装为字符串
let error_body = if let Some(body_str) = upstream_body {
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
// 上游返回的是 JSON,直接透传
json_body
} else {
// 上游返回的不是 JSON,包装为错误消息
json!({
"error": {
"message": body_str,
"type": "upstream_error",
}
})
}
} else {
json!({
"error": {
"message": format!("Upstream error (status {})", upstream_status),
"type": "upstream_error",
}
})
};
(http_status, error_body)
}
_ => {
let (http_status, message) = match &self {
ProxyError::AlreadyRunning => (StatusCode::CONFLICT, self.to_string()),
ProxyError::NotRunning => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::ProviderUnhealthy(_) => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::MaxRetriesExceeded => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::DatabaseError(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::ConfigError(_) => (StatusCode::BAD_REQUEST, self.to_string()),
ProxyError::TransformError(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
ProxyError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
ProxyError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, self.to_string()),
ProxyError::StreamIdleTimeout(_) => {
(StatusCode::GATEWAY_TIMEOUT, self.to_string())
}
ProxyError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
ProxyError::Internal(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
ProxyError::UpstreamError { .. } => unreachable!(),
};
let error_body = json!({
"error": {
"message": message,
"type": "proxy_error",
}
});
(http_status, error_body)
}
};
(status, Json(body)).into_response()
}
}
/// 错误分类
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCategory {
/// 可重试错误(网络问题、5xx)
Retryable, // 网络超时、5xx 错误
/// 不可重试错误(4xx、认证失败)
NonRetryable, // 认证失败、参数错误、4xx 错误
#[allow(dead_code)]
ClientAbort, // 客户端主动中断
}
/// 判断错误是否可重试
#[allow(dead_code)]
pub fn categorize_error(error: &reqwest::Error) -> ErrorCategory {
if error.is_timeout() || error.is_connect() {
return ErrorCategory::Retryable;
}
if let Some(status) = error.status() {
if status.is_server_error() {
ErrorCategory::Retryable
} else if status.is_client_error() {
ErrorCategory::NonRetryable
} else {
ErrorCategory::Retryable
}
} else {
ErrorCategory::Retryable
}
}
+394
View File
@@ -0,0 +1,394 @@
//! 请求转发器
//!
//! 负责将请求转发到上游Provider,支持重试和故障转移
use super::{
error::*,
provider_router::ProviderRouter as NewProviderRouter,
providers::{get_adapter, ProviderAdapter},
types::ProxyStatus,
ProxyError,
};
use crate::{app_config::AppType, database::Database, provider::Provider};
use reqwest::{Client, Response};
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
pub struct RequestForwarder {
client: Client,
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 {
pub fn new(
db: Arc<Database>,
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 {
client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
}
let client = client_builder
.build()
.expect("Failed to create HTTP client");
Self {
client,
router: Arc::new(NewProviderRouter::new(db)),
max_retries,
status,
current_providers,
}
}
/// 转发请求(带故障转移)
pub async fn forward_with_retry(
&self,
app_type: &AppType,
endpoint: &str,
body: Value,
headers: axum::http::HeaderMap,
) -> Result<Response, ProxyError> {
// 获取适配器
let adapter = get_adapter(app_type);
let app_type_str = app_type.as_str();
// 使用新的 ProviderRouter 选择所有可用供应商
let providers = self
.router
.select_providers(app_type_str)
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
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.sort_index.unwrap_or(999999)
);
// 更新状态中的当前Provider信息
{
let mut status = self.status.write().await;
status.current_provider = Some(provider.name.clone());
status.current_provider_id = Some(provider.id.clone());
status.total_requests += 1;
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
if attempt > 0 {
failover_happened = true;
}
}
let start = Instant::now();
// 转发请求
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
let latency = start.elapsed().as_millis() as u64;
// 成功:记录成功并更新熔断器
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()),
);
}
// 更新成功统计
{
let mut status = self.status.write().await;
status.success_requests += 1;
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 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
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 => {
// 可重试:更新错误信息,继续尝试下一个供应商
{
let mut status = self.status.write().await;
status.last_error =
Some(format!("Provider {} 失败: {}", provider.name, e));
}
log::warn!(
"[{}] 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;
status.last_error = Some(e.to_string());
if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32
/ status.total_requests as f32)
* 100.0;
}
}
log::error!(
"[{}] Provider {} 失败(不可重试): {}",
app_type_str,
provider.name,
e
);
return Err(e);
}
}
}
}
}
// 所有供应商都失败了
{
let mut status = self.status.write().await;
status.failed_requests += 1;
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;
}
}
log::error!(
"[{}] 所有 {} 个供应商都失败了",
app_type_str,
providers.len()
);
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
}
/// 转发单个请求(使用适配器)
async fn forward(
&self,
provider: &Provider,
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
adapter: &dyn ProviderAdapter,
) -> Result<Response, ProxyError> {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
log::info!("[{}] base_url: {}", adapter.name(), base_url);
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, endpoint);
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
// 记录原始请求 JSON
log::info!(
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
);
// 转换请求体(如果需要)
let request_body = if needs_transform {
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
let transformed = adapter.transform_request(body.clone(), provider)?;
log::info!(
"[{}] >>> 转换后的请求 JSON:\n{}",
adapter.name(),
serde_json::to_string_pretty(&transformed).unwrap_or_default()
);
transformed
} else {
body.clone()
};
log::info!(
"[{}] 转发请求: {} -> {}",
adapter.name(),
provider.name,
url
);
// 构建请求
let mut request = self.client.post(&url);
// 只透传必要的 Headers(白名单模式)
let allowed_headers = [
"accept",
"user-agent",
"x-request-id",
"x-stainless-arch",
"x-stainless-lang",
"x-stainless-os",
"x-stainless-package-version",
"x-stainless-runtime",
"x-stainless-runtime-version",
];
for (key, value) in headers {
let key_str = key.as_str().to_lowercase();
if allowed_headers.contains(&key_str.as_str()) {
request = request.header(key, value);
}
}
// 确保 Content-Type 是 json
request = request.header("Content-Type", "application/json");
// 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) {
log::debug!(
"[{}] 使用认证: {:?} (key: {})",
adapter.name(),
auth.strategy,
auth.masked_key()
);
request = adapter.add_auth_headers(request, &auth);
} else {
log::error!(
"[{}] 未找到 API KeyProvider: {}",
adapter.name(),
provider.name
);
}
// 发送请求
log::info!("[{}] 发送请求到: {}", adapter.name(), url);
let response = request.json(&request_body).send().await.map_err(|e| {
log::error!("[{}] 请求失败: {}", adapter.name(), e);
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {e}"))
} else {
ProxyError::ForwardFailed(e.to_string())
}
})?;
// 检查响应状态
let status = response.status();
log::info!("[{}] 响应状态: {}", adapter.name(), status);
if status.is_success() {
Ok(response)
} else {
let status_code = status.as_u16();
let body_text = response.text().await.ok();
log::error!(
"[{}] 上游错误 ({}): {:?}",
adapter.name(),
status_code,
body_text
);
Err(ProxyError::UpstreamError {
status: status_code,
body: body_text,
})
}
}
/// 分类ProxyError
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error {
ProxyError::Timeout(_) => ErrorCategory::Retryable,
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
ProxyError::UpstreamError { status, .. } => {
if *status >= 500 {
ErrorCategory::Retryable
} else if *status >= 400 && *status < 500 {
ErrorCategory::NonRetryable
} else {
ErrorCategory::Retryable
}
}
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
_ => ErrorCategory::NonRetryable,
}
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
//! 健康检查器
//!
//! 负责定期检查Provider健康状态(占位实现)
// 占位实现,稍后添加完整逻辑
#[allow(dead_code)]
pub struct HealthChecker;
+38
View File
@@ -0,0 +1,38 @@
//! 代理服务器模块
//!
//! 提供本地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;
pub(crate) mod server;
pub mod session;
pub(crate) mod types;
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};
#[allow(unused_imports)]
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
// 内部模块间共享(供子模块使用)
// 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用
#[allow(unused_imports)]
pub(crate) use types::*;
+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);
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Provider Adapter Trait
//!
//! 定义供应商适配器的统一接口,抽象不同上游供应商的处理逻辑。
use super::auth::AuthInfo;
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
use serde_json::Value;
/// 供应商适配器 Trait
///
/// 所有供应商适配器都需要实现此 trait,提供统一的接口来处理:
/// - URL 构建
/// - 认证信息提取和头部注入
/// - 请求/响应格式转换(可选)
///
/// # 示例
///
/// ```ignore
/// pub struct ClaudeAdapter;
///
/// impl ProviderAdapter for ClaudeAdapter {
/// fn name(&self) -> &'static str { "Claude" }
///
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
/// // 从 provider 配置中提取 base_url
/// }
///
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
/// // 从 provider 配置中提取认证信息
/// }
///
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
/// }
///
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
/// // 添加认证头
/// }
/// }
/// ```
pub trait ProviderAdapter: Send + Sync {
/// 适配器名称(用于日志和调试)
fn name(&self) -> &'static str;
/// 从 Provider 配置中提取 base_url
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
/// * `Err(ProxyError)` - 提取失败
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
/// 从 Provider 配置中提取认证信息
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Some(AuthInfo)` - 提取到的认证信息
/// * `None` - 未找到认证信息
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
/// 构建请求 URL
///
/// # Arguments
/// * `base_url` - 基础 URL
/// * `endpoint` - 请求端点(如 `/v1/messages`
///
/// # Returns
/// 完整的请求 URL
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
/// 添加认证头到请求
///
/// # Arguments
/// * `request` - reqwest RequestBuilder
/// * `auth` - 认证信息
///
/// # Returns
/// 添加了认证头的 RequestBuilder
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
/// 是否需要格式转换
///
/// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
///
/// # Arguments
/// * `provider` - Provider 配置
fn needs_transform(&self, _provider: &Provider) -> bool {
false
}
/// 转换请求体
///
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
/// 默认实现直接返回原始请求体(透传)。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `provider` - Provider 配置(用于获取模型映射等)
///
/// # Returns
/// * `Ok(Value)` - 转换后的请求体
/// * `Err(ProxyError)` - 转换失败
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
Ok(body)
}
/// 转换响应体
///
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
/// 默认实现直接返回原始响应体(透传)。
///
/// # Arguments
/// * `body` - 原始响应体
///
/// # Returns
/// * `Ok(Value)` - 转换后的响应体
/// * `Err(ProxyError)` - 转换失败
///
/// Note: 响应转换将在 handler 层集成,目前预留接口
#[allow(dead_code)]
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
Ok(body)
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Authentication Types
//!
//! 定义认证信息和认证策略,支持多种上游供应商的认证方式。
/// 认证信息
///
/// 包含 API Key 和对应的认证策略
#[derive(Debug, Clone)]
pub struct AuthInfo {
/// API Key
pub api_key: String,
/// 认证策略
pub strategy: AuthStrategy,
/// OAuth access_token(用于 GoogleOAuth 策略)
pub access_token: Option<String>,
}
impl AuthInfo {
/// 创建新的认证信息
pub fn new(api_key: String, strategy: AuthStrategy) -> Self {
Self {
api_key,
strategy,
access_token: None,
}
}
/// 创建带有 access_token 的认证信息(用于 OAuth
pub fn with_access_token(api_key: String, access_token: String) -> Self {
Self {
api_key,
strategy: AuthStrategy::GoogleOAuth,
access_token: Some(access_token),
}
}
/// 返回遮蔽后的 API Key(用于日志输出)
///
/// 显示前4位和后4位,中间用 `...` 代替
/// 如果 key 长度不足8位,则返回 `***`
pub fn masked_key(&self) -> String {
if self.api_key.len() > 8 {
format!(
"{}...{}",
&self.api_key[..4],
&self.api_key[self.api_key.len() - 4..]
)
} else {
"***".to_string()
}
}
/// 返回遮蔽后的 access_token(用于日志输出)
#[allow(dead_code)]
pub fn masked_access_token(&self) -> Option<String> {
self.access_token.as_ref().map(|token| {
if token.len() > 8 {
format!("{}...{}", &token[..4], &token[token.len() - 4..])
} else {
"***".to_string()
}
})
}
}
/// 认证策略
///
/// 不同供应商使用不同的认证方式
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthStrategy {
/// Anthropic 认证方式
/// - Header: `x-api-key: <api_key>`
/// - Header: `anthropic-version: 2023-06-01`
Anthropic,
/// Claude 中转服务认证方式(仅 Bearer,无 x-api-key
///
/// - Header: `Authorization: Bearer <api_key>`
///
/// 用于不支持 x-api-key 的中转服务
ClaudeAuth,
/// Bearer Token 认证方式(OpenAI 等)
///
/// - Header: `Authorization: Bearer <api_key>`
Bearer,
/// Google API Key 认证方式
///
/// - Header: `x-goog-api-key: <api_key>`
Google,
/// Google OAuth 认证方式
///
/// - Header: `Authorization: Bearer <access_token>`
///
/// 用于 Gemini CLI 等需要 OAuth 的场景
GoogleOAuth,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_masked_key_long() {
let auth = AuthInfo::new("sk-1234567890abcdef".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "sk-1...cdef");
}
#[test]
fn test_masked_key_short() {
let auth = AuthInfo::new("short".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "***");
}
#[test]
fn test_masked_key_exactly_8() {
let auth = AuthInfo::new("12345678".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "***");
}
#[test]
fn test_masked_key_9_chars() {
let auth = AuthInfo::new("123456789".to_string(), AuthStrategy::Bearer);
assert_eq!(auth.masked_key(), "1234...6789");
}
#[test]
fn test_auth_strategy_equality() {
assert_eq!(AuthStrategy::Anthropic, AuthStrategy::Anthropic);
assert_ne!(AuthStrategy::Anthropic, AuthStrategy::Bearer);
assert_ne!(AuthStrategy::Bearer, AuthStrategy::Google);
}
#[test]
fn test_auth_info_new_has_no_access_token() {
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
assert!(auth.access_token.is_none());
}
#[test]
fn test_auth_info_with_access_token() {
let auth = AuthInfo::with_access_token(
"refresh-token".to_string(),
"ya29.access-token-12345".to_string(),
);
assert_eq!(auth.api_key, "refresh-token");
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(
auth.access_token,
Some("ya29.access-token-12345".to_string())
);
}
#[test]
fn test_masked_access_token_long() {
let auth =
AuthInfo::with_access_token("refresh".to_string(), "ya29.1234567890abcdef".to_string());
assert_eq!(auth.masked_access_token(), Some("ya29...cdef".to_string()));
}
#[test]
fn test_masked_access_token_short() {
let auth = AuthInfo::with_access_token("refresh".to_string(), "short".to_string());
assert_eq!(auth.masked_access_token(), Some("***".to_string()));
}
#[test]
fn test_masked_access_token_none() {
let auth = AuthInfo::new("api-key".to_string(), AuthStrategy::Bearer);
assert!(auth.masked_access_token().is_none());
}
#[test]
fn test_claude_auth_strategy() {
let auth = AuthInfo::new("sk-test".to_string(), AuthStrategy::ClaudeAuth);
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
assert_ne!(auth.strategy, AuthStrategy::Anthropic);
assert_ne!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_google_oauth_strategy() {
let auth = AuthInfo::new("refresh-token".to_string(), AuthStrategy::GoogleOAuth);
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_ne!(auth.strategy, AuthStrategy::Google);
}
#[test]
fn test_all_strategies_are_distinct() {
let strategies = [
AuthStrategy::Anthropic,
AuthStrategy::ClaudeAuth,
AuthStrategy::Bearer,
AuthStrategy::Google,
AuthStrategy::GoogleOAuth,
];
for (i, s1) in strategies.iter().enumerate() {
for (j, s2) in strategies.iter().enumerate() {
if i == j {
assert_eq!(s1, s2);
} else {
assert_ne!(s1, s2);
}
}
}
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Claude (Anthropic) Provider Adapter
//!
//! 支持透传模式和 OpenRouter 兼容模式
//!
//! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Claude 适配器
pub struct ClaudeAdapter;
impl ClaudeAdapter {
pub fn new() -> Self {
Self
}
/// 获取供应商类型
///
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
/// - OpenRouter: base_url 包含 openrouter.ai
/// - ClaudeAuth: auth_mode 为 bearer_only
/// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 OpenRouter
if let Ok(base_url) = self.extract_base_url(provider) {
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
}
// 检测 ClaudeAuth (仅 Bearer 认证)
if self.is_bearer_only_mode(provider) {
return ProviderType::ClaudeAuth;
}
ProviderType::Claude
}
/// 检测是否使用 OpenRouter
fn is_openrouter(&self, provider: &Provider) -> bool {
if let Ok(base_url) = self.extract_base_url(provider) {
return base_url.contains("openrouter.ai");
}
false
}
/// 检测是否为仅 Bearer 认证模式
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
// 检查 settings_config 中的 auth_mode
if let Some(auth_mode) = provider
.settings_config
.get("auth_mode")
.and_then(|v| v.as_str())
{
if auth_mode == "bearer_only" {
return true;
}
}
// 检查 env 中的 AUTH_MODE
if let Some(env) = provider.settings_config.get("env") {
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
if auth_mode == "bearer_only" {
return true;
}
}
}
false
}
/// 从 Provider 配置中提取 API Key
fn extract_key(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// Anthropic 标准 key
if let Some(key) = env
.get("ANTHROPIC_AUTH_TOKEN")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
return Some(key.to_string());
}
// OpenRouter key
if let Some(key) = env
.get("OPENROUTER_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
return Some(key.to_string());
}
// 备选 OpenAI key (用于 OpenRouter)
if let Some(key) = env
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENAI_API_KEY");
return Some(key.to_string());
}
}
// 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 apiKey/api_key");
return Some(key.to_string());
}
log::warn!("[Claude] 未找到有效的 API Key");
None
}
}
impl Default for ClaudeAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for ClaudeAdapter {
fn name(&self) -> &'static str {
"Claude"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 1. 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
}
// 2. 尝试直接获取
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("apiEndpoint")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
Err(ProxyError::ConfigError(
"Claude Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
let provider_type = self.provider_type(provider);
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
_ => AuthStrategy::Anthropic,
};
self.extract_key(provider)
.map(|key| AuthInfo::new(key, strategy))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
// OpenRouter 使用 /v1/chat/completions
if base_url.contains("openrouter.ai") {
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
}
// Anthropic 直连
format!(
"{}/{}",
base_url.trim_end_matches('/'),
endpoint.trim_start_matches('/')
)
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
match auth.strategy {
// Anthropic 官方: Authorization Bearer + x-api-key + anthropic-version
AuthStrategy::Anthropic => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01"),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
// OpenRouter: Bearer
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
_ => request,
}
}
fn needs_transform(&self, provider: &Provider) -> bool {
self.is_openrouter(provider)
}
fn transform_request(
&self,
body: serde_json::Value,
provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
super::transform::anthropic_to_openai(body, provider)
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
super::transform::openai_to_anthropic(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Claude".to_string(),
settings_config: config,
website_url: None,
category: Some("claude".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://api.anthropic.com");
}
#[test]
fn test_extract_auth_anthropic() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-ant-test-key");
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
}
#[test]
fn test_extract_auth_openrouter() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-or-test-key");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_claude_auth_mode() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
},
"auth_mode": "bearer_only"
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-proxy-key");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_extract_auth_claude_auth_env_mode() {
let adapter = ClaudeAdapter::new();
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
"AUTH_MODE": "bearer_only"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-proxy-key");
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
#[test]
fn test_provider_type_detection() {
let adapter = ClaudeAdapter::new();
// Anthropic 官方
let anthropic = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
}
}));
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
// OpenRouter
let openrouter = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test"
}
}));
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
// ClaudeAuth
let claude_auth = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-test"
},
"auth_mode": "bearer_only"
}));
assert_eq!(
adapter.provider_type(&claude_auth),
ProviderType::ClaudeAuth
);
}
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
}
#[test]
fn test_needs_transform() {
let adapter = ClaudeAdapter::new();
let anthropic_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}));
assert!(!adapter.needs_transform(&anthropic_provider));
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
assert!(adapter.needs_transform(&openrouter_provider));
}
}
+265
View File
@@ -0,0 +1,265 @@
//! Codex (OpenAI) Provider Adapter
//!
//! 仅透传模式,支持直连 OpenAI API
//!
//! ## 客户端检测
//! 支持检测官方 Codex 客户端 (codex_vscode, codex_cli_rs)
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use regex::Regex;
use reqwest::RequestBuilder;
use std::sync::LazyLock;
/// 官方 Codex 客户端 User-Agent 正则
#[allow(dead_code)]
static CODEX_CLIENT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(codex_vscode|codex_cli_rs)/[\d.]+").unwrap());
/// Codex 适配器
pub struct CodexAdapter;
impl CodexAdapter {
pub fn new() -> Self {
Self
}
/// 检测是否为官方 Codex 客户端
///
/// 匹配 User-Agent 模式: `^(codex_vscode|codex_cli_rs)/[\d.]+`
#[allow(dead_code)]
pub fn is_official_client(user_agent: &str) -> bool {
CODEX_CLIENT_REGEX.is_match(user_agent)
}
/// 从 Provider 配置中提取 API Key
fn extract_key(&self, provider: &Provider) -> Option<String> {
// 1. 尝试从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(key) = env.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 2. 尝试从 auth 中获取 (Codex CLI 格式)
if let Some(auth) = provider.settings_config.get("auth") {
if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 3. 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
// 4. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(key) = config
.get("api_key")
.or_else(|| config.get("apiKey"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
}
None
}
}
impl Default for CodexAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for CodexAdapter {
fn name(&self) -> &'static str {
"Codex"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 1. 尝试直接获取 base_url 字段
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 2. 尝试 baseURL
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
// 3. 尝试从 config 对象中获取
if let Some(config) = provider.settings_config.get("config") {
if let Some(url) = config.get("base_url").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
// 尝试解析 TOML 字符串格式
if let Some(config_str) = config.as_str() {
if let Some(start) = config_str.find("base_url = \"") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('"') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
if let Some(start) = config_str.find("base_url = '") {
let rest = &config_str[start + 12..];
if let Some(end) = rest.find('\'') {
return Ok(rest[..end].trim_end_matches('/').to_string());
}
}
}
}
Err(ProxyError::ConfigError(
"Codex Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
self.extract_key(provider)
.map(|key| AuthInfo::new(key, AuthStrategy::Bearer))
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 去除重复的 /v1/v1
if url.contains("/v1/v1") {
url = url.replace("/v1/v1", "/v1");
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
request.header("Authorization", format!("Bearer {}", auth.api_key))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Codex".to_string(),
settings_config: config,
website_url: None,
category: Some("codex".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_extract_base_url_direct() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"base_url": "https://api.openai.com/v1"
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://api.openai.com/v1");
}
#[test]
fn test_extract_auth_from_auth_field() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"auth": {
"OPENAI_API_KEY": "sk-test-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-test-key-12345678");
assert_eq!(auth.strategy, AuthStrategy::Bearer);
}
#[test]
fn test_extract_auth_from_env() {
let adapter = CodexAdapter::new();
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-env-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "sk-env-key-12345678");
}
#[test]
fn test_build_url() {
let adapter = CodexAdapter::new();
let url = adapter.build_url("https://api.openai.com/v1", "/responses");
assert_eq!(url, "https://api.openai.com/v1/responses");
}
#[test]
fn test_build_url_dedup_v1() {
let adapter = CodexAdapter::new();
// base_url 已包含 /v1endpoint 也包含 /v1
let url = adapter.build_url("https://www.packyapi.com/v1", "/v1/responses");
assert_eq!(url, "https://www.packyapi.com/v1/responses");
}
// 官方客户端检测测试
#[test]
fn test_is_official_client_vscode() {
assert!(CodexAdapter::is_official_client("codex_vscode/1.0.0"));
assert!(CodexAdapter::is_official_client("codex_vscode/2.3.4"));
assert!(CodexAdapter::is_official_client("codex_vscode/0.1"));
}
#[test]
fn test_is_official_client_cli() {
assert!(CodexAdapter::is_official_client("codex_cli_rs/1.0.0"));
assert!(CodexAdapter::is_official_client("codex_cli_rs/0.5.2"));
}
#[test]
fn test_is_not_official_client() {
assert!(!CodexAdapter::is_official_client("Mozilla/5.0"));
assert!(!CodexAdapter::is_official_client("curl/7.68.0"));
assert!(!CodexAdapter::is_official_client("python-requests/2.25.1"));
assert!(!CodexAdapter::is_official_client("codex_other/1.0.0"));
assert!(!CodexAdapter::is_official_client(""));
}
#[test]
fn test_is_official_client_partial_match() {
// 必须从开头匹配
assert!(!CodexAdapter::is_official_client("some codex_vscode/1.0.0"));
assert!(!CodexAdapter::is_official_client(
"prefix_codex_cli_rs/1.0.0"
));
}
}
+426
View File
@@ -0,0 +1,426 @@
//! Gemini (Google) Provider Adapter
//!
//! 支持 API Key 和 OAuth 两种认证方式
//!
//! ## 认证模式
//! - **Gemini**: API Key 认证 (x-goog-api-key)
//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Gemini 适配器
pub struct GeminiAdapter;
/// OAuth 凭证结构
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct OAuthCredentials {
pub access_token: String,
pub refresh_token: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
}
#[allow(dead_code)]
impl OAuthCredentials {
/// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token
pub fn needs_refresh(&self) -> bool {
self.refresh_token.is_some() && self.access_token.is_empty()
}
/// 检查是否可以刷新 token
pub fn can_refresh(&self) -> bool {
self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()
}
}
impl GeminiAdapter {
pub fn new() -> Self {
Self
}
/// 获取供应商类型
///
/// 根据 API Key 格式检测:
/// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证
/// - Gemini: 普通 API Key
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
if let Some(key) = self.extract_key_raw(provider) {
// OAuth access_token 以 ya29. 开头
if key.starts_with("ya29.") {
return ProviderType::GeminiCli;
}
// JSON 格式的 OAuth 凭证
if key.starts_with('{') {
return ProviderType::GeminiCli;
}
}
ProviderType::Gemini
}
/// 检测认证类型
pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {
match self.provider_type(provider) {
ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,
_ => AuthStrategy::Google,
}
}
/// 解析 OAuth 凭证
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
// 直接是 access_token
if key.starts_with("ya29.") {
return Some(OAuthCredentials {
access_token: key.to_string(),
refresh_token: None,
client_id: None,
client_secret: None,
});
}
// JSON 格式
if key.starts_with('{') {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let refresh_token = json
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let client_id = json
.get("client_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let client_secret = json
.get("client_secret")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 如果有 access_token 或 refresh_token,返回凭证
if !access_token.is_empty() || refresh_token.is_some() {
return Some(OAuthCredentials {
access_token,
refresh_token,
client_id,
client_secret,
});
}
}
}
None
}
/// 从 Provider 配置中提取原始 API Key
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// 优先使用 GOOGLE_GEMINI_API_KEY
if let Some(key) = env.get("GOOGLE_GEMINI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
// 备选 GEMINI_API_KEY
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
return Some(key.to_string());
}
}
// 尝试直接获取
if let Some(key) = provider
.settings_config
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
{
return Some(key.to_string());
}
None
}
}
impl Default for GeminiAdapter {
fn default() -> Self {
Self::new()
}
}
impl ProviderAdapter for GeminiAdapter {
fn name(&self) -> &'static str {
"Gemini"
}
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
// 从 env 中获取
if let Some(env) = provider.settings_config.get("env") {
if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) {
return Ok(url.trim_end_matches('/').to_string());
}
}
// 尝试直接获取
if let Some(url) = provider
.settings_config
.get("base_url")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
if let Some(url) = provider
.settings_config
.get("baseURL")
.and_then(|v| v.as_str())
{
return Ok(url.trim_end_matches('/').to_string());
}
Err(ProxyError::ConfigError(
"Gemini Provider 缺少 base_url 配置".to_string(),
))
}
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
let key = self.extract_key_raw(provider)?;
let strategy = self.detect_auth_type(provider);
match strategy {
AuthStrategy::GoogleOAuth => {
// 解析 OAuth 凭证
if let Some(creds) = self.parse_oauth_credentials(&key) {
Some(AuthInfo::with_access_token(key, creds.access_token))
} else {
// 回退到普通 API Key
Some(AuthInfo::new(key, AuthStrategy::Google))
}
}
_ => Some(AuthInfo::new(key, AuthStrategy::Google)),
}
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
let base_trimmed = base_url.trim_end_matches('/');
let endpoint_trimmed = endpoint.trim_start_matches('/');
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
// 处理 /v1beta 路径去重
let version_patterns = ["/v1beta", "/v1"];
for pattern in &version_patterns {
let duplicate = format!("{pattern}{pattern}");
if url.contains(&duplicate) {
url = url.replace(&duplicate, pattern);
}
}
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
match auth.strategy {
// OAuth Bearer 认证
AuthStrategy::GoogleOAuth => {
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
request
.header("Authorization", format!("Bearer {token}"))
.header("x-goog-api-client", "GeminiCLI/1.0")
}
// API Key 认证
_ => request.header("x-goog-api-key", &auth.api_key),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Gemini".to_string(),
settings_config: config,
website_url: None,
category: Some("gemini".to_string()),
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_extract_base_url_from_env() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta"
}
}));
let url = adapter.extract_base_url(&provider).unwrap();
assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta");
}
#[test]
fn test_extract_auth_api_key() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "AIza-test-key-12345678"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "AIza-test-key-12345678");
assert_eq!(auth.strategy, AuthStrategy::Google);
assert!(auth.access_token.is_none());
}
#[test]
fn test_extract_auth_oauth_access_token() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token-12345"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(
auth.access_token,
Some("ya29.test-access-token-12345".to_string())
);
}
#[test]
fn test_extract_auth_oauth_json() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
assert_eq!(auth.access_token, Some("ya29.test-token".to_string()));
}
#[test]
fn test_provider_type_detection() {
let adapter = GeminiAdapter::new();
// API Key
let api_key_provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
}
}));
assert_eq!(
adapter.provider_type(&api_key_provider),
ProviderType::Gemini
);
// OAuth access_token
let oauth_provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "ya29.test-token"
}
}));
assert_eq!(
adapter.provider_type(&oauth_provider),
ProviderType::GeminiCli
);
// OAuth JSON
let oauth_json_provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
}
}));
assert_eq!(
adapter.provider_type(&oauth_json_provider),
ProviderType::GeminiCli
);
}
#[test]
fn test_extract_auth_fallback() {
let adapter = GeminiAdapter::new();
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "AIza-fallback-key"
}
}));
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.api_key, "AIza-fallback-key");
}
#[test]
fn test_build_url_dedup() {
let adapter = GeminiAdapter::new();
// 模拟 base_url 已包含 /v1betaendpoint 也包含 /v1beta
let url = adapter.build_url(
"https://generativelanguage.googleapis.com/v1beta",
"/v1beta/models/gemini-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
);
}
#[test]
fn test_build_url_normal() {
let adapter = GeminiAdapter::new();
let url = adapter.build_url(
"https://generativelanguage.googleapis.com/v1beta",
"/models/gemini-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
);
}
#[test]
fn test_parse_oauth_credentials_direct_token() {
let adapter = GeminiAdapter::new();
let creds = adapter
.parse_oauth_credentials("ya29.test-access-token")
.unwrap();
assert_eq!(creds.access_token, "ya29.test-access-token");
assert!(creds.refresh_token.is_none());
}
#[test]
fn test_parse_oauth_credentials_json() {
let adapter = GeminiAdapter::new();
let creds = adapter
.parse_oauth_credentials(
"{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//refresh\"}",
)
.unwrap();
assert_eq!(creds.access_token, "ya29.test");
assert_eq!(creds.refresh_token, Some("1//refresh".to_string()));
}
#[test]
fn test_parse_oauth_credentials_invalid() {
let adapter = GeminiAdapter::new();
assert!(adapter.parse_oauth_credentials("AIza-api-key").is_none());
assert!(adapter.parse_oauth_credentials("invalid-json{").is_none());
}
}
+424
View File
@@ -0,0 +1,424 @@
//! Provider Adapters Module
//!
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
//!
//! ## 模块结构
//! - `adapter`: 定义 `ProviderAdapter` trait
//! - `auth`: 认证类型和策略
//! - `claude`: Claude (Anthropic) 适配器
//! - `codex`: Codex (OpenAI) 适配器
//! - `gemini`: Gemini (Google) 适配器
//! - `models`: API 数据模型
//! - `transform`: 格式转换
mod adapter;
mod auth;
mod claude;
mod codex;
mod gemini;
pub mod models;
pub mod streaming;
pub mod transform;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
// 公开导出
pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
pub use claude::ClaudeAdapter;
pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter;
/// 供应商类型枚举
///
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType {
/// Anthropic 官方 API (x-api-key + anthropic-version)
Claude,
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
ClaudeAuth,
/// OpenAI Codex Response API
Codex,
/// Google Gemini API (x-goog-api-key)
Gemini,
/// Google Gemini CLI (OAuth Bearer)
GeminiCli,
/// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
OpenRouter,
}
impl ProviderType {
/// 是否需要格式转换
///
/// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
#[allow(dead_code)]
pub fn needs_transform(&self) -> bool {
matches!(self, ProviderType::OpenRouter)
}
/// 获取默认端点
#[allow(dead_code)]
pub fn default_endpoint(&self) -> &'static str {
match self {
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
ProviderType::Codex => "https://api.openai.com",
ProviderType::Gemini | ProviderType::GeminiCli => {
"https://generativelanguage.googleapis.com"
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
}
}
/// 从 AppType 和 Provider 配置推断供应商类型
///
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
#[allow(dead_code)]
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
match app_type {
AppType::Claude => {
// 检测是否为 OpenRouter
let adapter = ClaudeAdapter::new();
if let Ok(base_url) = adapter.extract_base_url(provider) {
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
}
// 检测是否为中转服务(仅 Bearer 认证)
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
// 我们通过检查 settings_config 中的配置来判断
// 检查 settings_config 中的 auth_mode
if let Some(auth_mode) = provider
.settings_config
.get("auth_mode")
.and_then(|v| v.as_str())
{
if auth_mode == "bearer_only" {
return ProviderType::ClaudeAuth;
}
}
// 检查 env 中的 auth_mode
if let Some(env) = provider.settings_config.get("env") {
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
if auth_mode == "bearer_only" {
return ProviderType::ClaudeAuth;
}
}
}
ProviderType::Claude
}
AppType::Codex => ProviderType::Codex,
AppType::Gemini => {
// 检测是否为 CLI 模式(OAuth
let adapter = GeminiAdapter::new();
if let Some(auth) = adapter.extract_auth(provider) {
let key = &auth.api_key;
// OAuth access_token 以 ya29. 开头
if key.starts_with("ya29.") {
return ProviderType::GeminiCli;
}
// JSON 格式的 OAuth 凭证
if key.starts_with('{') {
return ProviderType::GeminiCli;
}
}
ProviderType::Gemini
}
}
}
/// 转换为字符串表示
pub fn as_str(&self) -> &'static str {
match self {
ProviderType::Claude => "claude",
ProviderType::ClaudeAuth => "claude_auth",
ProviderType::Codex => "codex",
ProviderType::Gemini => "gemini",
ProviderType::GeminiCli => "gemini_cli",
ProviderType::OpenRouter => "openrouter",
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"claude" => Ok(ProviderType::Claude),
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
"codex" => Ok(ProviderType::Codex),
"gemini" => Ok(ProviderType::Gemini),
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
"openrouter" => Ok(ProviderType::OpenRouter),
_ => Err(format!("Invalid provider type: {s}")),
}
}
}
/// 根据 AppType 获取对应的适配器
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
match app_type {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
}
}
/// 根据 ProviderType 获取对应的适配器
#[allow(dead_code)]
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
match provider_type {
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
Box::new(ClaudeAdapter::new())
}
ProviderType::Codex => Box::new(CodexAdapter::new()),
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: config,
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
#[test]
fn test_provider_type_needs_transform() {
assert!(!ProviderType::Claude.needs_transform());
assert!(!ProviderType::ClaudeAuth.needs_transform());
assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform());
assert!(ProviderType::OpenRouter.needs_transform());
}
#[test]
fn test_provider_type_default_endpoint() {
assert_eq!(
ProviderType::Claude.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::ClaudeAuth.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::Codex.default_endpoint(),
"https://api.openai.com"
);
assert_eq!(
ProviderType::Gemini.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::GeminiCli.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::OpenRouter.default_endpoint(),
"https://openrouter.ai/api"
);
}
#[test]
fn test_provider_type_from_str() {
assert_eq!(
"claude".parse::<ProviderType>().unwrap(),
ProviderType::Claude
);
assert_eq!(
"claude_auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"claude-auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"codex".parse::<ProviderType>().unwrap(),
ProviderType::Codex
);
assert_eq!(
"gemini".parse::<ProviderType>().unwrap(),
ProviderType::Gemini
);
assert_eq!(
"gemini_cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"gemini-cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"openrouter".parse::<ProviderType>().unwrap(),
ProviderType::OpenRouter
);
assert!("invalid".parse::<ProviderType>().is_err());
}
#[test]
fn test_provider_type_as_str() {
assert_eq!(ProviderType::Claude.as_str(), "claude");
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
assert_eq!(ProviderType::Codex.as_str(), "codex");
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
}
#[test]
fn test_provider_type_serde() {
// Test serialization
let claude = ProviderType::Claude;
let serialized = serde_json::to_string(&claude).unwrap();
assert_eq!(serialized, "\"claude\"");
let claude_auth = ProviderType::ClaudeAuth;
let serialized = serde_json::to_string(&claude_auth).unwrap();
assert_eq!(serialized, "\"claude_auth\"");
// Test deserialization
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
assert_eq!(deserialized, ProviderType::Claude);
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
assert_eq!(deserialized, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_claude_direct() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::Claude);
}
#[test]
fn test_from_app_type_claude_openrouter() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::OpenRouter);
}
#[test]
fn test_from_app_type_claude_auth() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-test"
},
"auth_mode": "bearer_only"
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::ClaudeAuth);
}
#[test]
fn test_from_app_type_codex() {
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
assert_eq!(provider_type, ProviderType::Codex);
}
#[test]
fn test_from_app_type_gemini_api_key() {
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "AIza-test-key"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::Gemini);
}
#[test]
fn test_from_app_type_gemini_cli_oauth() {
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "ya29.test-access-token"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_gemini_cli_json() {
let provider = create_provider(json!({
"env": {
"GOOGLE_GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_get_adapter_for_provider_type() {
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
assert_eq!(adapter.name(), "Codex");
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
assert_eq!(adapter.name(), "Gemini");
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
assert_eq!(adapter.name(), "Gemini");
}
}
@@ -0,0 +1,104 @@
//! Anthropic API 数据模型
//!
//! 用于 Anthropic Messages API 的请求/响应格式转换
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Anthropic 请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicRequest {
pub model: String,
pub messages: Vec<AnthropicMessage>,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<Value>, // 可以是 String 或 Vec<SystemBlock>
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<AnthropicTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
}
/// Anthropic 消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicMessage {
pub role: String,
pub content: Value, // String 或 Vec<ContentBlock>
}
/// Anthropic 内容块
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AnthropicContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image { source: ImageSource },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Value,
},
#[serde(rename = "tool_result")]
ToolResult { tool_use_id: String, content: Value },
}
/// 图片来源
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
/// Anthropic 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: Value,
}
/// Anthropic 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicResponse {
pub id: String,
#[serde(rename = "type")]
pub response_type: String,
pub role: String,
pub content: Vec<AnthropicResponseContent>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequence: Option<String>,
pub usage: AnthropicUsage,
}
/// Anthropic 响应内容
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AnthropicResponseContent {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: Value,
},
}
/// Anthropic 使用量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicUsage {
pub input_tokens: u32,
pub output_tokens: u32,
}
@@ -0,0 +1,6 @@
//! API 数据模型
//!
//! 定义 Anthropic 和 OpenAI API 的请求/响应结构
pub mod anthropic;
pub mod openai;
@@ -0,0 +1,113 @@
//! OpenAI API 数据模型
//!
//! 用于 OpenAI Chat Completions API 的请求/响应格式转换
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// OpenAI 请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIRequest {
pub model: String,
pub messages: Vec<OpenAIMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<OpenAITool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
}
/// OpenAI 消息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Value>, // String 或 Vec<ContentPart>
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<OpenAIToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
/// OpenAI 内容部分
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum OpenAIContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
ImageUrl { image_url: ImageUrl },
}
/// 图片 URL
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUrl {
pub url: String,
}
/// OpenAI 工具调用
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: OpenAIFunction,
}
/// OpenAI 函数
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIFunction {
pub name: String,
pub arguments: String, // JSON 字符串
}
/// OpenAI 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAITool {
#[serde(rename = "type")]
pub tool_type: String,
pub function: OpenAIFunctionDef,
}
/// OpenAI 函数定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIFunctionDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parameters: Value,
}
/// OpenAI 响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<OpenAIChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<OpenAIUsage>,
}
/// OpenAI 选择
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIChoice {
pub index: u32,
pub message: OpenAIMessage,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
/// OpenAI 使用量
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAIUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
+338
View File
@@ -0,0 +1,338 @@
//! 流式响应转换模块
//!
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
/// OpenAI 流式响应数据结构
#[derive(Debug, Deserialize)]
struct OpenAIStreamChunk {
id: String,
model: String,
choices: Vec<StreamChoice>,
#[serde(default)]
usage: Option<Usage>,
}
#[derive(Debug, Deserialize)]
struct StreamChoice {
delta: Delta,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Delta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning: Option<String>, // OpenRouter 的推理内容
#[serde(default)]
tool_calls: Option<Vec<DeltaToolCall>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct DeltaToolCall {
index: usize,
#[serde(default)]
id: Option<String>,
#[serde(rename = "type", default)]
call_type: Option<String>,
#[serde(default)]
function: Option<DeltaFunction>,
}
#[derive(Debug, Deserialize, Serialize)]
struct DeltaFunction {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<String>,
}
/// OpenAI 流式响应的 usage 信息(完整版)
#[derive(Debug, Deserialize)]
struct Usage {
#[serde(default)]
prompt_tokens: u32,
#[serde(default)]
completion_tokens: u32,
}
/// 创建 Anthropic SSE 流
pub fn create_anthropic_sse_stream(
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut message_id = None;
let mut current_model = None;
let mut content_index = 0;
let mut has_sent_message_start = false;
let mut current_block_type: Option<String> = None;
let mut tool_call_id = None;
log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======");
tokio::pin!(stream);
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
while let Some(pos) = buffer.find("\n\n") {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
if line.trim().is_empty() {
continue;
}
for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
let sse_data = format!("event: message_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
yield Ok(Bytes::from(sse_data));
continue;
}
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
// 记录原始 OpenAI 事件(格式化显示)
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(data) {
log::info!(
"[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}",
serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string())
);
} else {
log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}");
}
if message_id.is_none() {
message_id = Some(chunk.id.clone());
}
if current_model.is_none() {
current_model = Some(chunk.model.clone());
}
if let Some(choice) = chunk.choices.first() {
if !has_sent_message_start {
let event = json!({
"type": "message_start",
"message": {
"id": message_id.clone().unwrap_or_default(),
"type": "message",
"role": "assistant",
"model": current_model.clone().unwrap_or_default(),
"usage": {
"input_tokens": 0,
"output_tokens": 0
}
}
});
let sse_data = format!("event: message_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
has_sent_message_start = true;
}
// 处理 reasoningthinking
if let Some(reasoning) = &choice.delta.reasoning {
if current_block_type.is_none() {
let event = json!({
"type": "content_block_start",
"index": content_index,
"content_block": {
"type": "thinking",
"thinking": ""
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("thinking".to_string());
}
let event = json!({
"type": "content_block_delta",
"index": content_index,
"delta": {
"type": "thinking_delta",
"thinking": reasoning
}
});
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
// 处理文本内容
if let Some(content) = &choice.delta.content {
if !content.is_empty() {
if current_block_type.as_deref() != Some("text") {
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
"index": content_index
});
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
content_index += 1;
}
let event = json!({
"type": "content_block_start",
"index": content_index,
"content_block": {
"type": "text",
"text": ""
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("text".to_string());
}
let event = json!({
"type": "content_block_delta",
"index": content_index,
"delta": {
"type": "text_delta",
"text": content
}
});
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
}
// 处理工具调用
if let Some(tool_calls) = &choice.delta.tool_calls {
for tool_call in tool_calls {
if let Some(id) = &tool_call.id {
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
"index": content_index
});
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
content_index += 1;
}
tool_call_id = Some(id.clone());
}
if let Some(function) = &tool_call.function {
if let Some(name) = &function.name {
let event = json!({
"type": "content_block_start",
"index": content_index,
"content_block": {
"type": "tool_use",
"id": tool_call_id.clone().unwrap_or_default(),
"name": name
}
});
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
current_block_type = Some("tool_use".to_string());
}
if let Some(args) = &function.arguments {
let event = json!({
"type": "content_block_delta",
"index": content_index,
"delta": {
"type": "input_json_delta",
"partial_json": args
}
});
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
}
}
}
// 处理 finish_reason
if let Some(finish_reason) = &choice.finish_reason {
if current_block_type.is_some() {
let event = json!({
"type": "content_block_stop",
"index": content_index
});
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
let stop_reason = map_stop_reason(Some(finish_reason));
// 构建 usage 信息,包含 input_tokens 和 output_tokens
let usage_json = chunk.usage.as_ref().map(|u| json!({
"input_tokens": u.prompt_tokens,
"output_tokens": u.completion_tokens
}));
let event = json!({
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": null
},
"usage": usage_json
});
let sse_data = format!("event: message_delta\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
}
}
}
}
}
}
}
Err(e) => {
log::error!("Stream error: {e}");
let error_event = json!({
"type": "error",
"error": {
"type": "stream_error",
"message": format!("Stream error: {e}")
}
});
let sse_data = format!("event: error\ndata: {}\n\n",
serde_json::to_string(&error_event).unwrap_or_default());
yield Ok(Bytes::from(sse_data));
break;
}
}
}
}
}
/// 映射停止原因
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
finish_reason.map(|r| {
match r {
"tool_calls" => "tool_use",
"stop" => "end_turn",
"length" => "max_tokens",
_ => "end_turn",
}
.to_string()
})
}
+640
View File
@@ -0,0 +1,640 @@
//! 格式转换模块
//!
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
//! 参考: anthropic-proxy-rs
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
/// 从 Provider 配置中获取模型映射
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
let env = provider.settings_config.get("env");
let model_lower = model.to_lowercase();
// 检测 thinking 参数
let has_thinking = body
.get("thinking")
.and_then(|v| v.as_object())
.and_then(|o| o.get("type"))
.and_then(|t| t.as_str())
== Some("enabled");
if let Some(env) = env {
// 如果启用 thinking,优先使用推理模型
if has_thinking {
if let Some(m) = env
.get("ANTHROPIC_REASONING_MODEL")
.and_then(|v| v.as_str())
{
log::debug!("[Transform] 使用推理模型: {m}");
return m.to_string();
}
}
// 根据模型类型选择配置模型
if model_lower.contains("haiku") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("opus") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
if model_lower.contains("sonnet") {
if let Some(m) = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
{
return m.to_string();
}
}
// 默认使用 ANTHROPIC_MODEL
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
return m.to_string();
}
}
model.to_string()
}
/// Anthropic 请求 → OpenAI 请求
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
let mut result = json!({});
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
let mapped_model = get_model_from_provider(model, provider, &body);
result["model"] = json!(mapped_model);
}
let mut messages = Vec::new();
// 处理 system prompt
if let Some(system) = body.get("system") {
if let Some(text) = system.as_str() {
// 单个字符串
messages.push(json!({"role": "system", "content": text}));
} else if let Some(arr) = system.as_array() {
// 多个 system message
for msg in arr {
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
messages.push(json!({"role": "system", "content": text}));
}
}
}
}
// 转换 messages
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
for msg in msgs {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content");
let converted = convert_message_to_openai(role, content)?;
messages.extend(converted);
}
}
result["messages"] = json!(messages);
// 转换参数
if let Some(v) = body.get("max_tokens") {
result["max_tokens"] = v.clone();
}
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
if let Some(v) = body.get("stop_sequences") {
result["stop"] = v.clone();
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
.iter()
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
.map(|t| {
json!({
"type": "function",
"function": {
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
"description": t.get("description"),
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
}
})
})
.collect();
if !openai_tools.is_empty() {
result["tools"] = json!(openai_tools);
}
}
if let Some(v) = body.get("tool_choice") {
result["tool_choice"] = v.clone();
}
Ok(result)
}
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
fn convert_message_to_openai(
role: &str,
content: Option<&Value>,
) -> Result<Vec<Value>, ProxyError> {
let mut result = Vec::new();
let content = match content {
Some(c) => c,
None => {
result.push(json!({"role": role, "content": null}));
return Ok(result);
}
};
// 字符串内容
if let Some(text) = content.as_str() {
result.push(json!({"role": role, "content": text}));
return Ok(result);
}
// 数组内容(多模态/工具调用)
if let Some(blocks) = content.as_array() {
let mut content_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
content_parts.push(json!({"type": "text", "text": text}));
}
}
"image" => {
if let Some(source) = block.get("source") {
let media_type = source
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("image/png");
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
content_parts.push(json!({
"type": "image_url",
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
}));
}
}
"tool_use" => {
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(&input).unwrap_or_default()
}
}));
}
"tool_result" => {
// tool_result 变成单独的 tool role 消息
let tool_use_id = block
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("");
let content_val = block.get("content");
let content_str = match content_val {
Some(Value::String(s)) => s.clone(),
Some(v) => serde_json::to_string(v).unwrap_or_default(),
None => String::new(),
};
result.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content_str
}));
}
"thinking" => {
// 跳过 thinking blocks
}
_ => {}
}
}
// 添加带内容和/或工具调用的消息
if !content_parts.is_empty() || !tool_calls.is_empty() {
let mut msg = json!({"role": role});
// 内容处理
if content_parts.is_empty() {
msg["content"] = Value::Null;
} else if content_parts.len() == 1 {
if let Some(text) = content_parts[0].get("text") {
msg["content"] = text.clone();
} else {
msg["content"] = json!(content_parts);
}
} else {
msg["content"] = json!(content_parts);
}
// 工具调用
if !tool_calls.is_empty() {
msg["tool_calls"] = json!(tool_calls);
}
result.push(msg);
}
return Ok(result);
}
// 其他情况直接透传
result.push(json!({"role": role, "content": content}));
Ok(result)
}
/// 清理 JSON schema(移除不支持的 format
fn clean_schema(mut schema: Value) -> Value {
if let Some(obj) = schema.as_object_mut() {
// 移除 "format": "uri"
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
obj.remove("format");
}
// 递归清理嵌套 schema
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
for (_, value) in properties.iter_mut() {
*value = clean_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = clean_schema(items.clone());
}
}
schema
}
/// OpenAI 响应 → Anthropic 响应
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
let choices = body
.get("choices")
.and_then(|c| c.as_array())
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
let choice = choices
.first()
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
let message = choice
.get("message")
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
let mut content = Vec::new();
// 文本内容
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
}
// 工具调用
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
for tc in tool_calls {
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
let empty_obj = json!({});
let func = tc.get("function").unwrap_or(&empty_obj);
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args_str = func
.get("arguments")
.and_then(|a| a.as_str())
.unwrap_or("{}");
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input
}));
}
}
// 映射 finish_reason → stop_reason
let stop_reason = choice
.get("finish_reason")
.and_then(|r| r.as_str())
.map(|r| match r {
"stop" => "end_turn",
"length" => "max_tokens",
"tool_calls" => "tool_use",
other => other,
});
// usage
let usage = body.get("usage").cloned().unwrap_or(json!({}));
let input_tokens = usage
.get("prompt_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let output_tokens = usage
.get("completion_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let result = json!({
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
"type": "message",
"role": "assistant",
"content": content,
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
"stop_reason": stop_reason,
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_provider(env_config: Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: json!({"env": env_config}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
is_proxy_target: None,
}
}
fn create_openrouter_provider() -> Provider {
create_provider(json!({
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
}))
}
#[test]
fn test_anthropic_to_openai_simple() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"], "Hello");
}
#[test]
fn test_anthropic_to_openai_with_system() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are a helpful assistant."
);
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_with_tools() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "assistant",
"content": [
{"type": "text", "text": "Let me check"},
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
}
#[test]
fn test_anthropic_to_openai_tool_result() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
]
}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
assert_eq!(msg["content"], "Sunny, 25°C");
}
#[test]
fn test_openai_to_anthropic_simple() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["id"], "chatcmpl-123");
assert_eq!(result["type"], "message");
assert_eq!(result["content"][0]["type"], "text");
assert_eq!(result["content"][0]["text"], "Hello!");
assert_eq!(result["stop_reason"], "end_turn");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
}
#[test]
fn test_openai_to_anthropic_with_tool_calls() {
let input = json!({
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
});
let result = openai_to_anthropic(input).unwrap();
assert_eq!(result["content"][0]["type"], "tool_use");
assert_eq!(result["content"][0]["id"], "call_123");
assert_eq!(result["content"][0]["name"], "get_weather");
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
assert_eq!(result["stop_reason"], "tool_use");
}
#[test]
fn test_model_mapping_from_provider() {
let provider = create_openrouter_provider();
let body = json!({"model": "test"});
// sonnet 模型
assert_eq!(
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
"anthropic/claude-sonnet-4.5"
);
// haiku 模型
assert_eq!(
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
"anthropic/claude-haiku-4.5"
);
// opus 模型
assert_eq!(
get_model_from_provider("claude-opus-4-5", &provider, &body),
"anthropic/claude-opus-4.5"
);
}
#[test]
fn test_anthropic_to_openai_model_mapping() {
let provider = create_openrouter_provider();
let input = json!({
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
#[test]
fn test_thinking_parameter_detection() {
let mut provider = create_openrouter_provider();
// 添加推理模型配置
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "enabled"},
"messages": [{"role": "user", "content": "Solve this problem"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用推理模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
}
#[test]
fn test_thinking_parameter_disabled() {
let mut provider = create_openrouter_provider();
if let Some(env) = provider.settings_config.get_mut("env") {
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
}
let input = json!({
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"thinking": {"type": "disabled"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, &provider).unwrap();
// 应该使用普通模型
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Response Handler - 统一响应处理
//!
//! 提供流式和非流式响应的统一处理接口
use super::session::ProxySession;
use super::usage::parser::TokenUsage;
use super::ProxyError;
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio::time::timeout;
/// 响应类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ResponseType {
/// 流式响应 (SSE)
Stream,
/// 非流式响应
NonStream,
}
impl ResponseType {
/// 从 Content-Type 检测响应类型
#[allow(dead_code)]
pub fn from_content_type(content_type: &str) -> Self {
if content_type.contains("text/event-stream") {
ResponseType::Stream
} else {
ResponseType::NonStream
}
}
}
/// 流式响应处理器
#[allow(dead_code)]
pub struct StreamHandler {
/// 空闲超时时间
idle_timeout: Duration,
/// 收集的事件
events: Arc<Mutex<Vec<Value>>>,
}
#[allow(dead_code)]
impl StreamHandler {
/// 创建新的流式处理器
pub fn new(idle_timeout_secs: u64) -> Self {
Self {
idle_timeout: Duration::from_secs(idle_timeout_secs),
events: Arc::new(Mutex::new(Vec::new())),
}
}
/// 处理流式响应,返回分流后的客户端流
///
/// 客户端流立即返回,内部流在后台收集事件
pub fn handle_stream<S>(
&self,
stream: S,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
where
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
{
let events = self.events.clone();
let idle_timeout = self.idle_timeout;
async_stream::stream! {
let mut _last_activity = Instant::now();
let mut buffer = String::new();
tokio::pin!(stream);
loop {
let chunk_result = timeout(idle_timeout, stream.next()).await;
match chunk_result {
Ok(Some(Ok(bytes))) => {
_last_activity = Instant::now();
// 解析 SSE 事件
let text = String::from_utf8_lossy(&bytes);
buffer.push_str(&text);
// 提取完整事件
while let Some(pos) = buffer.find("\n\n") {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
for line in event_text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data.trim() != "[DONE]" {
if let Ok(json) = serde_json::from_str::<Value>(data) {
let mut guard = events.lock().await;
guard.push(json);
}
}
}
}
}
yield Ok(bytes);
}
Ok(Some(Err(e))) => {
log::error!("流错误: {e}");
yield Err(std::io::Error::other(e.to_string()));
break;
}
Ok(None) => {
// 流结束
break;
}
Err(_) => {
// 空闲超时
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
yield Err(std::io::Error::other("Stream idle timeout"));
break;
}
}
}
}
}
/// 获取收集的事件
pub async fn get_events(&self) -> Vec<Value> {
let guard = self.events.lock().await;
guard.clone()
}
/// 从收集的事件中提取 Token 使用量
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
let events = self.get_events().await;
match session.client_format {
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
TokenUsage::from_gemini_stream_chunks(&events)
}
_ => None,
}
}
}
/// 非流式响应处理器
#[allow(dead_code)]
pub struct NonStreamHandler;
#[allow(dead_code)]
impl NonStreamHandler {
/// 处理非流式响应
///
/// 克隆响应体用于后台解析,原始响应立即返回
pub async fn handle_response(
body: &[u8],
session: &ProxySession,
) -> Result<Option<TokenUsage>, ProxyError> {
let json: Value = serde_json::from_slice(body)
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
let usage = match session.client_format {
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
TokenUsage::from_gemini_response(&json)
}
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
_ => None,
};
Ok(usage)
}
}
/// 统一响应分发器
#[allow(dead_code)]
pub struct ResponseDispatcher;
#[allow(dead_code)]
impl ResponseDispatcher {
/// 判断响应类型
pub fn detect_type(content_type: &str) -> ResponseType {
ResponseType::from_content_type(content_type)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_type_detection() {
assert_eq!(
ResponseType::from_content_type("text/event-stream"),
ResponseType::Stream
);
assert_eq!(
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
ResponseType::Stream
);
assert_eq!(
ResponseType::from_content_type("application/json"),
ResponseType::NonStream
);
}
#[test]
fn test_stream_handler_creation() {
let handler = StreamHandler::new(30);
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Provider路由器
//!
//! 负责选择合适的Provider进行请求转发
use super::ProxyError;
use crate::{app_config::AppType, database::Database, provider::Provider};
use std::sync::Arc;
pub struct ProviderRouter {
db: Arc<Database>,
}
impl ProviderRouter {
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
/// 选择Provider(只使用标记为代理目标的 Provider)
pub async fn select_provider(
&self,
app_type: &AppType,
_failed_ids: &[String],
) -> Result<Provider, ProxyError> {
// 1. 获取 Proxy Target Provider ID
let proxy_target_id = self
.db
.get_proxy_target_provider(app_type.as_str())
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let target_id = proxy_target_id.ok_or_else(|| {
log::warn!("[{}] 未设置代理目标 Provider", app_type.as_str());
ProxyError::NoAvailableProvider
})?;
// 2. 获取所有 Provider
let providers = self
.db
.get_all_providers(app_type.as_str())
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
// 3. 找到目标 Provider
let target = providers.get(&target_id).ok_or_else(|| {
log::warn!(
"[{}] 代理目标 Provider 不存在: {}",
app_type.as_str(),
target_id
);
ProxyError::NoAvailableProvider
})?;
log::info!(
"[{}] 使用代理目标 Provider: {}",
app_type.as_str(),
target.name
);
Ok(target.clone())
}
/// 更新Provider健康状态(保留接口但不影响选择)
#[allow(dead_code)]
pub async fn update_health(
&self,
_provider: &Provider,
_app_type: &AppType,
_success: bool,
_error_msg: Option<String>,
) {
// 不再记录健康状态
}
}
+170
View File
@@ -0,0 +1,170 @@
//! HTTP代理服务器
//!
//! 基于Axum的HTTP服务器,处理代理请求
use super::{handlers, types::*, ProxyError};
use crate::database::Database;
use axum::{
routing::{get, post},
Router,
};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{oneshot, RwLock};
use tower_http::cors::{Any, CorsLayer};
/// 代理服务器状态(共享)
#[derive(Clone)]
pub struct ProxyState {
pub db: Arc<Database>,
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服务器
pub struct ProxyServer {
config: ProxyConfig,
state: ProxyState,
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
}
impl ProxyServer {
pub fn new(config: ProxyConfig, db: Arc<Database>) -> Self {
let state = ProxyState {
db,
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 {
config,
state,
shutdown_tx: Arc::new(RwLock::new(None)),
}
}
pub async fn start(&self) -> Result<ProxyServerInfo, ProxyError> {
// 检查是否已在运行
if self.shutdown_tx.read().await.is_some() {
return Err(ProxyError::AlreadyRunning);
}
let addr: SocketAddr =
format!("{}:{}", self.config.listen_address, self.config.listen_port)
.parse()
.map_err(|e| ProxyError::BindFailed(format!("无效的地址: {e}")))?;
// 创建关闭通道
let (shutdown_tx, shutdown_rx) = oneshot::channel();
// 构建路由
let app = self.build_router();
// 绑定监听器
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
log::info!("代理服务器启动于 {addr}");
// 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx);
// 更新状态
let mut status = self.state.status.write().await;
status.running = true;
status.address = self.config.listen_address.clone();
status.port = self.config.listen_port;
drop(status);
// 记录启动时间
*self.state.start_time.write().await = Some(std::time::Instant::now());
// 启动服务器
let state = self.state.clone();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await
.ok();
// 服务器停止后更新状态
state.status.write().await.running = false;
*state.start_time.write().await = None;
});
Ok(ProxyServerInfo {
address: self.config.listen_address.clone(),
port: self.config.listen_port,
started_at: chrono::Utc::now().to_rfc3339(),
})
}
pub async fn stop(&self) -> Result<(), ProxyError> {
if let Some(tx) = self.shutdown_tx.write().await.take() {
let _ = tx.send(());
Ok(())
} else {
Err(ProxyError::NotRunning)
}
}
pub async fn get_status(&self) -> ProxyStatus {
let mut status = self.state.status.read().await.clone();
// 计算运行时间
if let Some(start) = *self.state.start_time.read().await {
status.uptime_seconds = start.elapsed().as_secs();
}
// 从 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
}
fn build_router(&self) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
// 健康检查
.route("/health", get(handlers::health_check))
.route("/status", get(handlers::get_status))
// Claude API
.route("/v1/messages", post(handlers::handle_messages))
// OpenAI Chat Completions API (Codex CLI)
.route(
"/v1/chat/completions",
post(handlers::handle_chat_completions),
)
// OpenAI Responses API (Codex CLI)
.route("/v1/responses", post(handlers::handle_responses))
// Gemini API
.route("/v1beta/*path", post(handlers::handle_gemini))
.layer(cors)
.with_state(self.state.clone())
}
/// 在不重启服务的情况下更新运行时配置
pub async fn apply_runtime_config(&self, config: &ProxyConfig) {
*self.state.config.write().await = config.clone();
}
}
+298
View File
@@ -0,0 +1,298 @@
//! Proxy Session - 请求会话管理
//!
//! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。
use std::time::Instant;
use uuid::Uuid;
/// 客户端请求格式
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ClientFormat {
/// Claude Messages API (/v1/messages)
Claude,
/// Codex Response API (/v1/responses)
Codex,
/// OpenAI Chat Completions API (/v1/chat/completions)
OpenAI,
/// Gemini API (/v1beta/models/*/generateContent)
Gemini,
/// Gemini CLI API (/v1internal/models/*/generateContent)
GeminiCli,
/// 未知格式
Unknown,
}
#[allow(dead_code)]
impl ClientFormat {
/// 从请求路径检测格式
pub fn from_path(path: &str) -> Self {
if path.contains("/v1/messages") {
ClientFormat::Claude
} else if path.contains("/v1/responses") {
ClientFormat::Codex
} else if path.contains("/v1/chat/completions") {
ClientFormat::OpenAI
} else if path.contains("/v1internal/") && path.contains("generateContent") {
// Gemini CLI 使用 /v1internal/ 路径
ClientFormat::GeminiCli
} else if (path.contains("/v1beta/") || path.contains("/v1/"))
&& path.contains("generateContent")
{
// Gemini API 使用 /v1beta/ 或 /v1/ 路径
ClientFormat::Gemini
} else if path.contains("generateContent") {
// 通用 Gemini 端点
ClientFormat::Gemini
} else {
ClientFormat::Unknown
}
}
/// 从请求体内容检测格式(回退方案)
pub fn from_body(body: &serde_json::Value) -> Self {
// Claude 格式特征: messages 数组 + model 字段 + 无 response_format
if body.get("messages").is_some()
&& body.get("model").is_some()
&& body.get("response_format").is_none()
&& body.get("contents").is_none()
{
// 区分 Claude 和 OpenAI
if body.get("max_tokens").is_some() {
return ClientFormat::Claude;
}
return ClientFormat::OpenAI;
}
// Codex 格式特征: input 字段
if body.get("input").is_some() {
return ClientFormat::Codex;
}
// Gemini 格式特征: contents 数组
if body.get("contents").is_some() {
return ClientFormat::Gemini;
}
ClientFormat::Unknown
}
/// 转换为字符串
pub fn as_str(&self) -> &'static str {
match self {
ClientFormat::Claude => "claude",
ClientFormat::Codex => "codex",
ClientFormat::OpenAI => "openai",
ClientFormat::Gemini => "gemini",
ClientFormat::GeminiCli => "gemini_cli",
ClientFormat::Unknown => "unknown",
}
}
}
impl std::fmt::Display for ClientFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// 代理会话
///
/// 包含请求全生命周期的上下文数据
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ProxySession {
/// 唯一会话 ID
pub session_id: String,
/// 请求开始时间
pub start_time: Instant,
/// HTTP 方法
pub method: String,
/// 请求 URL
pub request_url: String,
/// User-Agent
pub user_agent: Option<String>,
/// 客户端请求格式
pub client_format: ClientFormat,
/// 选定的供应商 ID
pub provider_id: Option<String>,
/// 模型名称
pub model: Option<String>,
/// 是否为流式请求
pub is_streaming: bool,
}
#[allow(dead_code)]
impl ProxySession {
/// 从请求创建会话
pub fn from_request(
method: &str,
request_url: &str,
user_agent: Option<&str>,
body: Option<&serde_json::Value>,
) -> Self {
// 检测客户端格式
let mut client_format = ClientFormat::from_path(request_url);
if client_format == ClientFormat::Unknown {
if let Some(body) = body {
client_format = ClientFormat::from_body(body);
}
}
// 检测是否为流式请求
let is_streaming = body
.and_then(|b| b.get("stream"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
// 提取模型名称
let model = body
.and_then(|b| b.get("model"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Self {
session_id: Uuid::new_v4().to_string(),
start_time: Instant::now(),
method: method.to_string(),
request_url: request_url.to_string(),
user_agent: user_agent.map(|s| s.to_string()),
client_format,
provider_id: None,
model,
is_streaming,
}
}
/// 设置供应商 ID
pub fn with_provider(mut self, provider_id: &str) -> Self {
self.provider_id = Some(provider_id.to_string());
self
}
/// 获取请求延迟(毫秒)
pub fn latency_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_client_format_from_path_claude() {
assert_eq!(
ClientFormat::from_path("/v1/messages"),
ClientFormat::Claude
);
assert_eq!(
ClientFormat::from_path("/api/v1/messages"),
ClientFormat::Claude
);
}
#[test]
fn test_client_format_from_path_codex() {
assert_eq!(
ClientFormat::from_path("/v1/responses"),
ClientFormat::Codex
);
}
#[test]
fn test_client_format_from_path_openai() {
assert_eq!(
ClientFormat::from_path("/v1/chat/completions"),
ClientFormat::OpenAI
);
}
#[test]
fn test_client_format_from_path_gemini() {
assert_eq!(
ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"),
ClientFormat::Gemini
);
}
#[test]
fn test_client_format_from_path_gemini_cli() {
assert_eq!(
ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"),
ClientFormat::GeminiCli
);
}
#[test]
fn test_client_format_from_body_claude() {
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 1024
});
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);
}
#[test]
fn test_client_format_from_body_codex() {
let body = json!({
"input": "Write a function"
});
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);
}
#[test]
fn test_client_format_from_body_gemini() {
let body = json!({
"contents": [{"parts": [{"text": "Hello"}]}]
});
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);
}
#[test]
fn test_session_id_uniqueness() {
let session1 = ProxySession::from_request("POST", "/v1/messages", None, None);
let session2 = ProxySession::from_request("POST", "/v1/messages", None, None);
assert_ne!(session1.session_id, session2.session_id);
}
#[test]
fn test_session_from_request() {
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 1024,
"stream": true
});
let session =
ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body));
assert_eq!(session.method, "POST");
assert_eq!(session.request_url, "/v1/messages");
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
assert_eq!(session.client_format, ClientFormat::Claude);
assert_eq!(session.model, Some("claude-3-5-sonnet".to_string()));
assert!(session.is_streaming);
}
#[test]
fn test_session_with_provider() {
let session = ProxySession::from_request("POST", "/v1/messages", None, None)
.with_provider("provider-123");
assert_eq!(session.provider_id, Some("provider-123".to_string()));
}
#[test]
fn test_client_format_as_str() {
assert_eq!(ClientFormat::Claude.as_str(), "claude");
assert_eq!(ClientFormat::Codex.as_str(), "codex");
assert_eq!(ClientFormat::OpenAI.as_str(), "openai");
assert_eq!(ClientFormat::Gemini.as_str(), "gemini");
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
}
}
+119
View File
@@ -0,0 +1,119 @@
use serde::{Deserialize, Serialize};
/// 代理服务器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig {
/// 是否启用代理服务
pub enabled: bool,
/// 监听地址
pub listen_address: String,
/// 监听端口
pub listen_port: u16,
/// 最大重试次数
pub max_retries: u8,
/// 请求超时时间(秒)
pub request_timeout: u64,
/// 是否启用日志
pub enable_logging: bool,
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
enabled: false,
listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3,
request_timeout: 300,
enable_logging: true,
}
}
}
/// 代理服务器状态
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyStatus {
/// 是否运行中
pub running: bool,
/// 监听地址
pub address: String,
/// 监听端口
pub port: u16,
/// 活跃连接数
pub active_connections: usize,
/// 总请求数
pub total_requests: u64,
/// 成功请求数
pub success_requests: u64,
/// 失败请求数
pub failed_requests: u64,
/// 成功率 (0-100)
pub success_rate: f32,
/// 运行时间(秒)
pub uptime_seconds: u64,
/// 当前使用的Provider名称
pub current_provider: Option<String>,
/// 当前Provider的ID
pub current_provider_id: Option<String>,
/// 最后一次请求时间
pub last_request_at: Option<String>,
/// 最后一次错误信息
pub last_error: Option<String>,
/// Provider故障转移次数
pub failover_count: u64,
/// 当前活跃的代理目标列表
#[serde(default)]
pub active_targets: Vec<ActiveTarget>,
}
/// 活跃的代理目标信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveTarget {
pub app_type: String, // "Claude" | "Codex" | "Gemini"
pub provider_name: String,
pub provider_id: String,
}
/// 代理服务器信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyServerInfo {
pub address: String,
pub port: u16,
pub started_at: String,
}
/// API 格式类型(预留,当前不需要格式转换)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ApiFormat {
Claude,
OpenAI,
Gemini,
}
/// Provider健康状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderHealth {
pub provider_id: String,
pub app_type: String,
pub is_healthy: bool,
pub consecutive_failures: u32,
pub last_success_at: Option<String>,
pub last_failure_at: Option<String>,
pub last_error: Option<String>,
pub updated_at: String,
}
/// 使用统计记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyUsageRecord {
pub provider_id: String,
pub app_type: String,
pub endpoint: String,
pub request_tokens: Option<i32>,
pub response_tokens: Option<i32>,
pub status_code: u16,
pub latency_ms: u64,
pub error: Option<String>,
pub timestamp: String,
}
+186
View File
@@ -0,0 +1,186 @@
//! Cost Calculator - 计算 API 请求成本
//!
//! 使用高精度 Decimal 类型避免浮点数精度问题
use super::parser::TokenUsage;
use rust_decimal::Decimal;
use std::str::FromStr;
/// 成本明细
#[derive(Debug, Clone)]
pub struct CostBreakdown {
pub input_cost: Decimal,
pub output_cost: Decimal,
pub cache_read_cost: Decimal,
pub cache_creation_cost: Decimal,
pub total_cost: Decimal,
}
/// 模型定价信息
#[derive(Debug, Clone)]
pub struct ModelPricing {
pub input_cost_per_million: Decimal,
pub output_cost_per_million: Decimal,
pub cache_read_cost_per_million: Decimal,
pub cache_creation_cost_per_million: Decimal,
}
/// 成本计算器
pub struct CostCalculator;
impl CostCalculator {
/// 计算请求成本
///
/// # 参数
/// - `usage`: Token 使用量
/// - `pricing`: 模型定价
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
pub fn calculate(
usage: &TokenUsage,
pricing: &ModelPricing,
cost_multiplier: Decimal,
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million
/ million
* cost_multiplier;
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
/ million
* cost_multiplier;
let cache_read_cost =
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
* cost_multiplier;
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
* pricing.cache_creation_cost_per_million
/ million
* cost_multiplier;
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
CostBreakdown {
input_cost,
output_cost,
cache_read_cost,
cache_creation_cost,
total_cost,
}
}
/// 尝试计算成本,如果模型未知则返回 None
pub fn try_calculate(
usage: &TokenUsage,
pricing: Option<&ModelPricing>,
cost_multiplier: Decimal,
) -> Option<CostBreakdown> {
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
}
}
impl ModelPricing {
/// 从字符串创建定价信息
pub fn from_strings(
input: &str,
output: &str,
cache_read: &str,
cache_creation: &str,
) -> Result<Self, rust_decimal::Error> {
Ok(Self {
input_cost_per_million: Decimal::from_str(input)?,
output_cost_per_million: Decimal::from_str(output)?,
cache_read_cost_per_million: Decimal::from_str(cache_read)?,
cache_creation_cost_per_million: Decimal::from_str(cache_creation)?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cost_calculation() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: 200,
cache_creation_tokens: 100,
model: None,
};
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
let multiplier = Decimal::from_str("1.0").unwrap();
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input: 1000 * 3.0 / 1M = 0.003
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
// output: 500 * 15.0 / 1M = 0.0075
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
// cache_read: 200 * 0.3 / 1M = 0.00006
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
// cache_creation: 100 * 3.75 / 1M = 0.000375
assert_eq!(
cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap()
);
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
}
#[test]
fn test_cost_multiplier() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
};
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
let multiplier = Decimal::from_str("1.5").unwrap();
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
}
#[test]
fn test_unknown_model_handling() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
};
let multiplier = Decimal::from_str("1.0").unwrap();
let cost = CostCalculator::try_calculate(&usage, None, multiplier);
assert!(cost.is_none());
}
#[test]
fn test_decimal_precision() {
let usage = TokenUsage {
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 1,
cache_creation_tokens: 1,
model: None,
};
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
let multiplier = Decimal::from_str("1.0").unwrap();
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// 验证高精度计算
assert!(cost.total_cost > Decimal::ZERO);
assert!(cost.total_cost.to_string().len() > 2); // 确保保留了小数位
}
}
+286
View File
@@ -0,0 +1,286 @@
//! Usage Logger - 记录 API 请求使用情况
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
use super::parser::TokenUsage;
use crate::database::Database;
use crate::error::AppError;
use crate::services::usage_stats::find_model_pricing_row;
use rust_decimal::Decimal;
use std::time::SystemTime;
/// 请求日志
#[derive(Debug, Clone)]
pub struct RequestLog {
pub request_id: String,
pub provider_id: String,
pub app_type: String,
pub model: String,
pub usage: TokenUsage,
pub cost: Option<CostBreakdown>,
pub latency_ms: u64,
pub first_token_ms: Option<u64>,
pub status_code: u16,
pub error_message: Option<String>,
pub session_id: Option<String>,
/// 供应商类型 (claude, claude_auth, codex, gemini, gemini_cli, openrouter)
pub provider_type: Option<String>,
/// 是否为流式请求
pub is_streaming: bool,
/// 成本倍数
pub cost_multiplier: String,
}
/// 使用量记录器
pub struct UsageLogger<'a> {
db: &'a Database,
}
impl<'a> UsageLogger<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// 记录成功的请求
pub fn log_request(&self, log: &RequestLog) -> Result<(), AppError> {
let conn = crate::database::lock_conn!(self.db.conn);
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) =
if let Some(cost) = &log.cost {
(
cost.input_cost.to_string(),
cost.output_cost.to_string(),
cost.cache_read_cost.to_string(),
cost.cache_creation_cost.to_string(),
cost.total_cost.to_string(),
)
} else {
(
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
"0".to_string(),
)
};
let created_at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
latency_ms, first_token_ms, status_code, error_message, session_id,
provider_type, is_streaming, cost_multiplier, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
rusqlite::params![
log.request_id,
log.provider_id,
log.app_type,
log.model,
log.usage.input_tokens,
log.usage.output_tokens,
log.usage.cache_read_tokens,
log.usage.cache_creation_tokens,
input_cost,
output_cost,
cache_read_cost,
cache_creation_cost,
total_cost,
log.latency_ms as i64,
log.first_token_ms.map(|v| v as i64),
log.status_code as i64,
log.error_message,
log.session_id,
log.provider_type,
log.is_streaming as i64,
log.cost_multiplier,
created_at,
],
)
.map_err(|e| AppError::Database(format!("记录请求日志失败: {e}")))?;
Ok(())
}
/// 记录失败的请求
#[allow(dead_code, clippy::too_many_arguments)]
pub fn log_error(
&self,
request_id: String,
provider_id: String,
app_type: String,
model: String,
status_code: u16,
error_message: String,
latency_ms: u64,
) -> Result<(), AppError> {
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
first_token_ms: None,
status_code,
error_message: Some(error_message),
session_id: None,
provider_type: None,
is_streaming: false,
cost_multiplier: "1.0".to_string(),
};
self.log_request(&log)
}
/// 获取模型定价
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
let conn = crate::database::lock_conn!(self.db.conn);
let row = find_model_pricing_row(&conn, model_id)?;
match row {
Some((input, output, cache_read, cache_creation)) => {
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)
.map(Some)
.map_err(|e| AppError::Database(format!("解析定价数据失败: {e}")))
}
None => Ok(None),
}
}
/// 计算并记录请求
#[allow(clippy::too_many_arguments)]
pub fn log_with_calculation(
&self,
request_id: String,
provider_id: String,
app_type: String,
model: String,
usage: TokenUsage,
cost_multiplier: Decimal,
latency_ms: u64,
first_token_ms: Option<u64>,
status_code: u16,
session_id: Option<String>,
provider_type: Option<String>,
is_streaming: bool,
) -> Result<(), AppError> {
let pricing = self.get_model_pricing(&model)?;
if pricing.is_none() {
log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0");
}
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
usage,
cost,
latency_ms,
first_token_ms,
status_code,
error_message: None,
session_id,
provider_type,
is_streaming,
cost_multiplier: cost_multiplier.to_string(),
};
self.log_request(&log)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_request() -> Result<(), AppError> {
let db = Database::memory()?;
// 插入测试定价
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
VALUES ('test-model', 'Test Model', '3.0', '15.0')",
[],
)
.unwrap();
}
let logger = UsageLogger::new(&db);
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
};
logger.log_with_calculation(
"req-123".to_string(),
"provider-1".to_string(),
"claude".to_string(),
"test-model".to_string(),
usage,
Decimal::from(1),
100,
None,
200,
None,
Some("claude".to_string()),
false,
)?;
// 验证记录已插入
let conn = crate::database::lock_conn!(db.conn);
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = 'req-123'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
Ok(())
}
#[test]
fn test_log_error() -> Result<(), AppError> {
let db = Database::memory()?;
let logger = UsageLogger::new(&db);
logger.log_error(
"req-error".to_string(),
"provider-1".to_string(),
"claude".to_string(),
"unknown-model".to_string(),
500,
"Internal Server Error".to_string(),
50,
)?;
// 验证错误记录已插入
let conn = crate::database::lock_conn!(db.conn);
let (status, error): (i64, Option<String>) = conn
.query_row(
"SELECT status_code, error_message FROM proxy_request_logs WHERE request_id = 'req-error'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(status, 500);
assert_eq!(error, Some("Internal Server Error".to_string()));
Ok(())
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Proxy Usage Tracking Module
//!
//! 提供 API 请求的使用量跟踪、成本计算和日志记录功能
pub mod calculator;
pub mod logger;
pub mod parser;
// 仅导出内部使用的类型,避免未使用警告
#[allow(unused_imports)]
pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
#[allow(unused_imports)]
pub use logger::{RequestLog, UsageLogger};
#[allow(unused_imports)]
pub use parser::{ApiType, TokenUsage};
+534
View File
@@ -0,0 +1,534 @@
//! Response Parser - 从 API 响应中提取 token 使用量
//!
//! 支持多种 API 格式:
//! - Claude API (非流式和流式)
//! - OpenRouter (OpenAI 格式)
//! - Codex API (非流式和流式)
//! - Gemini API (非流式和流式)
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Token 使用量统计
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage {
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_creation_tokens: u32,
/// 从响应中提取的实际模型名称(如果可用)
pub model: Option<String>,
}
/// API 类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ApiType {
Claude,
OpenRouter,
Codex,
Gemini,
}
impl TokenUsage {
/// 从 Claude API 非流式响应解析
pub fn from_claude_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
Some(Self {
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
cache_read_tokens: usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model: None,
})
}
/// 从 Claude API 流式响应解析
#[allow(dead_code)]
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
let mut usage = Self::default();
for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
match event_type {
"message_start" => {
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
// 从 message_start 获取 input_tokens(原生 Claude API
if let Some(input) =
msg_usage.get("input_tokens").and_then(|v| v.as_u64())
{
usage.input_tokens = input as u32;
}
usage.cache_read_tokens = msg_usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0)
as u32;
usage.cache_creation_tokens = msg_usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0)
as u32;
}
}
"message_delta" => {
if let Some(delta_usage) = event.get("usage") {
// 从 message_delta 获取 output_tokens
if let Some(output) =
delta_usage.get("output_tokens").and_then(|v| v.as_u64())
{
usage.output_tokens = output as u32;
}
// OpenRouter 转换后的流式响应:input_tokens 也在 message_delta 中
// 如果 message_start 中没有 input_tokens,则从 message_delta 获取
if usage.input_tokens == 0 {
if let Some(input) =
delta_usage.get("input_tokens").and_then(|v| v.as_u64())
{
usage.input_tokens = input as u32;
}
}
}
}
_ => {}
}
}
}
if usage.input_tokens > 0 || usage.output_tokens > 0 {
Some(usage)
} else {
None
}
}
/// 从 OpenRouter 响应解析 (OpenAI 格式)
#[allow(dead_code)]
pub fn from_openrouter_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
Some(Self {
input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32,
output_tokens: usage.get("completion_tokens")?.as_u64()? as u32,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
})
}
/// 从 Codex API 非流式响应解析
pub fn from_codex_response(body: &Value) -> Option<Self> {
let usage = body.get("usage");
if usage.is_none() {
log::debug!(
"[Codex] 响应中没有 usage 字段,body keys: {:?}",
body.as_object().map(|o| o.keys().collect::<Vec<_>>())
);
return None;
}
let usage = usage?;
let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64());
let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64());
if input_tokens.is_none() || output_tokens.is_none() {
log::debug!("[Codex] usage 字段缺少 input_tokens 或 output_tokensusage: {usage:?}");
return None;
}
Some(Self {
input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32,
cache_read_tokens: usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model: None,
})
}
/// 从 Codex API 响应解析并调整 input_tokens
///
/// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数
/// 公式: adjusted_input = max(input_tokens - cached_tokens, 0)
#[allow(dead_code)]
pub fn from_codex_response_adjusted(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 input_tokens_details 中)
let cached_tokens = usage
.get("input_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
// 调整 input_tokens: 减去 cached_tokens
let adjusted_input = input_tokens.saturating_sub(cached_tokens);
Some(Self {
input_tokens: adjusted_input,
output_tokens,
cache_read_tokens: cached_tokens,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model: None,
})
}
/// 从 Codex API 流式响应解析
#[allow(dead_code)]
pub fn from_codex_stream_events(events: &[Value]) -> Option<Self> {
log::debug!("[Codex] 解析流式事件,共 {} 个事件", events.len());
for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
log::debug!("[Codex] 事件类型: {event_type}");
if event_type == "response.completed" {
if let Some(response) = event.get("response") {
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
return Self::from_codex_response(response);
}
}
}
}
log::debug!("[Codex] 未找到 response.completed 事件");
None
}
/// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)
pub fn from_openai_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
// OpenAI 使用 prompt_tokens 和 completion_tokens
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64())?;
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64())?;
// 获取 cached_tokens (可能在 prompt_tokens_details 中)
let cached_tokens = usage
.get("prompt_tokens_details")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
Some(Self {
input_tokens: prompt_tokens as u32,
output_tokens: completion_tokens as u32,
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
model: None,
})
}
/// 从 OpenAI Chat Completions API 流式响应解析
pub fn from_openai_stream_events(events: &[Value]) -> Option<Self> {
log::debug!("[Codex] 解析 OpenAI 流式事件,共 {} 个事件", events.len());
// OpenAI 流式响应在最后一个 chunk 中包含 usage
for event in events.iter().rev() {
if let Some(usage) = event.get("usage") {
if !usage.is_null() {
log::debug!("[Codex] 找到 usage: {usage:?}");
return Self::from_openai_response(event);
}
}
}
log::debug!("[Codex] 未找到 usage 信息");
None
}
/// 从 Gemini API 非流式响应解析
pub fn from_gemini_response(body: &Value) -> Option<Self> {
let usage = body.get("usageMetadata")?;
// 提取实际使用的模型名称(modelVersion 字段)
let model = body
.get("modelVersion")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self {
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
cache_read_tokens: usage
.get("cachedContentTokenCount")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: 0,
model,
})
}
/// 从 Gemini API 流式响应解析
#[allow(dead_code)]
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
let mut total_input = 0u32;
let mut total_output = 0u32;
let mut total_cache_read = 0u32;
let mut model: Option<String> = None;
for chunk in chunks {
if let Some(usage) = chunk.get("usageMetadata") {
total_input = usage
.get("promptTokenCount")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
total_output += usage
.get("candidatesTokenCount")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
total_cache_read = usage
.get("cachedContentTokenCount")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
}
// 提取实际使用的模型名称(modelVersion 字段)
if model.is_none() {
if let Some(model_version) = chunk.get("modelVersion").and_then(|v| v.as_str()) {
model = Some(model_version.to_string());
}
}
}
if total_input > 0 || total_output > 0 {
Some(Self {
input_tokens: total_input,
output_tokens: total_output,
cache_read_tokens: total_cache_read,
cache_creation_tokens: 0,
model,
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_claude_response_parsing() {
let response = json!({
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 20,
"cache_creation_input_tokens": 10
}
});
let usage = TokenUsage::from_claude_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10);
}
#[test]
fn test_claude_stream_parsing() {
let events = vec![
json!({
"type": "message_start",
"message": {
"usage": {
"input_tokens": 100,
"cache_read_input_tokens": 20,
"cache_creation_input_tokens": 10
}
}
}),
json!({
"type": "message_delta",
"usage": {
"output_tokens": 50
}
}),
];
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 10);
}
#[test]
fn test_openrouter_response_parsing() {
let response = json!({
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50
}
});
let usage = TokenUsage::from_openrouter_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 0);
assert_eq!(usage.cache_creation_tokens, 0);
}
#[test]
fn test_gemini_response_parsing() {
let response = json!({
"modelVersion": "gemini-3-pro-high",
"usageMetadata": {
"promptTokenCount": 100,
"candidatesTokenCount": 50,
"cachedContentTokenCount": 20
}
});
let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
}
#[test]
fn test_gemini_response_parsing_no_model() {
// 测试没有 modelVersion 字段的情况
let response = json!({
"usageMetadata": {
"promptTokenCount": 100,
"candidatesTokenCount": 50,
"cachedContentTokenCount": 20
}
});
let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, None);
}
#[test]
fn test_codex_response_adjusted() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300
}
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
// input_tokens 应该被调整: 1000 - 300 = 700
assert_eq!(usage.input_tokens, 700);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 300);
}
#[test]
fn test_codex_response_adjusted_no_cache() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
// 没有 cached_tokensinput_tokens 保持不变
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn test_codex_response_adjusted_saturating_sub() {
// 测试 cached_tokens > input_tokens 的边界情况
let response = json!({
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"input_tokens_details": {
"cached_tokens": 200
}
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
// saturating_sub 确保不会下溢
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.cache_read_tokens, 200);
}
#[test]
fn test_openrouter_stream_parsing() {
// 测试 OpenRouter 转换后的流式响应解析
// OpenRouter 流式响应经过转换后,input_tokens 在 message_delta 中
let events = vec![
json!({
"type": "message_start",
"message": {
"usage": {
"input_tokens": 0,
"output_tokens": 0
}
}
}),
json!({
"type": "message_delta",
"delta": {
"stop_reason": "end_turn"
},
"usage": {
"input_tokens": 150,
"output_tokens": 75
}
}),
];
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 150);
assert_eq!(usage.output_tokens, 75);
}
#[test]
fn test_native_claude_stream_parsing() {
// 测试原生 Claude API 流式响应解析
// 原生 Claude API 的 input_tokens 在 message_start 中
let events = vec![
json!({
"type": "message_start",
"message": {
"usage": {
"input_tokens": 200,
"cache_read_input_tokens": 50
}
}
}),
json!({
"type": "message_delta",
"usage": {
"output_tokens": 100
}
}),
];
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
assert_eq!(usage.input_tokens, 200);
assert_eq!(usage.output_tokens, 100);
assert_eq!(usage.cache_read_tokens, 50);
}
}
+11
View File
@@ -2,14 +2,25 @@ pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod model_test;
pub mod prompt;
pub mod provider;
pub mod proxy;
pub mod skill;
pub mod speedtest;
pub mod usage_stats;
pub use config::ConfigService;
pub use mcp::McpService;
#[allow(unused_imports)]
pub use model_test::{ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService};
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
pub use skill::{Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)]
pub use usage_stats::{
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
RequestLogDetail, UsageSummary,
};
+510
View File
@@ -0,0 +1,510 @@
//! 模型测试服务
//!
//! 提供独立的模型可用性测试功能,复用现有 Provider 适配器逻辑,
//! 但不影响正常代理数据流程。测试结果记录到独立的日志表。
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::{get_adapter, AuthInfo, ProviderAdapter};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::{Duration, Instant};
/// 模型测试配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestConfig {
/// 默认测试模型(Claude
pub claude_model: String,
/// 默认测试模型(Codex/OpenAI
pub codex_model: String,
/// 默认测试模型(Gemini
pub gemini_model: String,
/// 测试提示词
pub test_prompt: String,
/// 超时时间(秒)
pub timeout_secs: u64,
}
impl Default for ModelTestConfig {
fn default() -> Self {
Self {
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-low".to_string(),
gemini_model: "gemini-3-pro-low".to_string(),
test_prompt: "ping".to_string(),
timeout_secs: 15,
}
}
}
/// 模型测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestResult {
pub success: bool,
pub message: String,
pub response_time_ms: Option<u64>,
pub http_status: Option<u16>,
pub model_used: String,
pub tested_at: i64,
}
/// 模型测试日志记录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestLog {
pub id: i64,
pub provider_id: String,
pub provider_name: String,
pub app_type: String,
pub model: String,
pub prompt: String,
pub success: bool,
pub message: String,
pub response_time_ms: Option<i64>,
pub http_status: Option<i64>,
pub tested_at: i64,
}
/// 模型测试服务
pub struct ModelTestService;
impl ModelTestService {
/// 测试单个供应商的模型可用性
pub async fn test_provider(
app_type: &AppType,
provider: &Provider,
config: &ModelTestConfig,
) -> Result<ModelTestResult, AppError> {
let start = Instant::now();
let adapter = get_adapter(app_type);
// 构建 HTTP 客户端(独立于代理服务)
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))?;
// 根据 AppType 选择测试模型
let model = match app_type {
AppType::Claude => &config.claude_model,
AppType::Codex => &config.codex_model,
AppType::Gemini => &config.gemini_model,
};
let result = match app_type {
AppType::Claude => {
Self::test_claude(
&client,
provider,
adapter.as_ref(),
model,
&config.test_prompt,
)
.await
}
AppType::Codex => {
Self::test_codex(
&client,
provider,
adapter.as_ref(),
model,
&config.test_prompt,
)
.await
}
AppType::Gemini => {
Self::test_gemini(
&client,
provider,
adapter.as_ref(),
model,
&config.test_prompt,
)
.await
}
};
let response_time = start.elapsed().as_millis() as u64;
let tested_at = chrono::Utc::now().timestamp();
match result {
Ok((status, msg)) => Ok(ModelTestResult {
success: true,
message: msg,
response_time_ms: Some(response_time),
http_status: Some(status),
model_used: model.clone(),
tested_at,
}),
Err(e) => Ok(ModelTestResult {
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: model.clone(),
tested_at,
}),
}
}
/// 测试 Claude (Anthropic Messages API)
async fn test_claude(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
// 智能拼接 URL,避免重复 /v1
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{base}/messages")
} else {
format!("{base}/v1/messages")
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{
"role": "user",
"content": prompt
}]
});
let mut request = client.post(&url).json(&body);
request = Self::add_claude_auth(request, &auth);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
// 先获取文本,再尝试解析 JSON(兼容流式响应)
let text = response.text().await.unwrap_or_default();
// 尝试解析 JSON
if let Ok(data) = serde_json::from_str::<Value>(&text) {
if data.get("type").is_some()
|| data.get("content").is_some()
|| data.get("id").is_some()
{
return Ok((status, "模型测试成功".to_string()));
}
}
// 即使无法解析 JSON,只要状态码是 200 就认为成功
Ok((status, "模型测试成功".to_string()))
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
}
}
/// 测试 Codex (OpenAI Chat Completions API)
async fn test_codex(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
// 智能拼接 URL,避免重复 /v1
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{base}/chat/completions")
} else {
format!("{base}/v1/chat/completions")
};
let body = json!({
"model": model,
"messages": [{
"role": "user",
"content": prompt
}],
"max_tokens": 1,
"stream": false
});
let request = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.json(&body);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
// 先获取文本,再尝试解析 JSON
let text = response.text().await.unwrap_or_default();
if let Ok(data) = serde_json::from_str::<Value>(&text) {
if data.get("choices").is_some() || data.get("id").is_some() {
return Ok((status, "模型测试成功".to_string()));
}
}
// 即使无法解析 JSON,只要状态码是 200 就认为成功
Ok((status, "模型测试成功".to_string()))
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
}
}
/// 测试 Gemini (Google Generative AI API)
async fn test_gemini(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter
.extract_base_url(provider)
.map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?;
let auth = adapter
.extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?;
let url = format!(
"{}/v1beta/models/{}:generateContent?key={}",
base_url.trim_end_matches('/'),
model,
auth.api_key
);
let body = json!({
"contents": [{
"parts": [{
"text": prompt
}]
}],
"generationConfig": {
"maxOutputTokens": 1
}
});
let request = client
.post(&url)
.header("Content-Type", "application/json")
.json(&body);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
let data: Value = response
.json()
.await
.map_err(|e| AppError::Message(format!("解析响应失败: {e}")))?;
if data.get("candidates").is_some() {
Ok((status, "模型测试成功".to_string()))
} else {
Err(AppError::Message("响应格式异常".to_string()))
}
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {status}: {error_text}")))
}
}
/// 添加 Claude 认证头
fn add_claude_auth(
request: reqwest::RequestBuilder,
auth: &AuthInfo,
) -> reqwest::RequestBuilder {
request
.header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
}
}
// ===== 数据库操作 =====
impl Database {
/// 保存模型测试日志
pub fn save_model_test_log(
&self,
provider_id: &str,
provider_name: &str,
app_type: &str,
model: &str,
prompt: &str,
result: &ModelTestResult,
) -> Result<i64, AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
conn.execute(
"INSERT INTO model_test_logs
(provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
provider_id,
provider_name,
app_type,
model,
prompt,
result.success,
result.message,
result.response_time_ms.map(|t| t as i64),
result.http_status.map(|s| s as i64),
result.tested_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
/// 获取模型测试日志
pub fn get_model_test_logs(
&self,
app_type: Option<&str>,
provider_id: Option<&str>,
limit: u32,
) -> Result<Vec<ModelTestLog>, AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
let mut sql = String::from(
"SELECT id, provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at
FROM model_test_logs WHERE 1=1"
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(at) = app_type {
sql.push_str(" AND app_type = ?");
params.push(Box::new(at.to_string()));
}
if let Some(pid) = provider_id {
sql.push_str(" AND provider_id = ?");
params.push(Box::new(pid.to_string()));
}
sql.push_str(" ORDER BY tested_at DESC LIMIT ?");
params.push(Box::new(limit as i64));
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| AppError::Database(e.to_string()))?;
let logs = stmt
.query_map(params_refs.as_slice(), |row| {
Ok(ModelTestLog {
id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
prompt: row.get(5)?,
success: row.get(6)?,
message: row.get(7)?,
response_time_ms: row.get(8)?,
http_status: row.get(9)?,
tested_at: row.get(10)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(logs)
}
/// 获取模型测试配置
pub fn get_model_test_config(&self) -> Result<ModelTestConfig, AppError> {
match self.get_setting("model_test_config")? {
Some(json) => serde_json::from_str(&json)
.map_err(|e| AppError::Message(format!("解析模型测试配置失败: {e}"))),
None => Ok(ModelTestConfig::default()),
}
}
/// 保存模型测试配置
pub fn save_model_test_config(&self, config: &ModelTestConfig) -> Result<(), AppError> {
let json = serde_json::to_string(config)
.map_err(|e| AppError::Message(format!("序列化模型测试配置失败: {e}")))?;
self.set_setting("model_test_config", &json)
}
/// 清理旧的测试日志(保留最近 N 条)
pub fn cleanup_model_test_logs(&self, keep_count: u32) -> Result<u64, AppError> {
let conn = self
.conn
.lock()
.map_err(|e| AppError::Database(format!("获取数据库连接失败: {e}")))?;
let deleted = conn
.execute(
"DELETE FROM model_test_logs WHERE id NOT IN (
SELECT id FROM model_test_logs ORDER BY tested_at DESC LIMIT ?
)",
rusqlite::params![keep_count as i64],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(deleted as u64)
}
}
+12 -41
View File
@@ -13,8 +13,6 @@ use serde::Deserialize;
use serde_json::Value;
use crate::app_config::AppType;
use crate::codex_config::write_codex_live_atomic;
use crate::config::{get_claude_settings_path, write_json_file};
use crate::error::AppError;
use crate::provider::{Provider, UsageResult};
use crate::services::mcp::McpService;
@@ -219,6 +217,18 @@ impl ProviderService {
Ok(())
}
/// Set proxy target provider
pub fn set_proxy_target(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.contains_key(id) {
return Err(AppError::Message(format!("供应商 {id} 不存在")));
}
state.db.set_proxy_target_provider(app_type.as_str(), id)?;
Ok(())
}
/// Sync current provider to live configuration (re-export)
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
sync_current_to_live(state)
@@ -329,36 +339,6 @@ impl ProviderService {
.await
}
#[allow(dead_code)]
fn write_codex_live(provider: &Provider) -> Result<(), AppError> {
let settings = provider
.settings_config
.as_object()
.ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
let auth = settings
.get("auth")
.ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?;
if !auth.is_object() {
return Err(AppError::Config(format!(
"供应商 {} 的 auth 必须是对象",
provider.id
)));
}
let cfg_text = settings.get("config").and_then(Value::as_str);
write_codex_live_atomic(auth, cfg_text)?;
Ok(())
}
#[allow(dead_code)]
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
let settings_path = get_claude_settings_path();
let mut content = provider.settings_config.clone();
let _ = normalize_claude_models_in_value(&mut content);
write_json_file(&settings_path, &content)?;
Ok(())
}
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
write_gemini_live(provider)
}
@@ -557,15 +537,6 @@ impl ProviderService {
}
}
}
#[allow(dead_code)]
fn app_not_found(app_type: &AppType) -> AppError {
AppError::localized(
"provider.app_not_found",
format!("应用类型不存在: {app_type:?}"),
format!("App type not found: {app_type:?}"),
)
}
}
/// Normalize Claude model keys in a JSON value
+153
View File
@@ -0,0 +1,153 @@
//! 代理服务业务逻辑层
//!
//! 提供代理服务器的启动、停止和配置管理
use crate::database::Database;
use crate::proxy::server::ProxyServer;
use crate::proxy::types::*;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct ProxyService {
db: Arc<Database>,
server: Arc<RwLock<Option<ProxyServer>>>,
}
impl ProxyService {
pub fn new(db: Arc<Database>) -> Self {
Self {
db,
server: Arc::new(RwLock::new(None)),
}
}
/// 启动代理服务器
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
// 1. 获取配置
let mut config = self
.db
.get_proxy_config()
.await
.map_err(|e| format!("获取代理配置失败: {e}"))?;
// 2. 确保配置启用(用户通过UI启动即表示希望启用)
config.enabled = true;
// 3. 检查是否已在运行
if self.server.read().await.is_some() {
return Err("代理服务已在运行中".to_string());
}
// 4. 创建并启动服务器
let server = ProxyServer::new(config.clone(), self.db.clone());
let info = server
.start()
.await
.map_err(|e| format!("启动代理服务器失败: {e}"))?;
// 5. 保存服务器实例
*self.server.write().await = Some(server);
// 6. 持久化 enabled 状态
self.db
.update_proxy_config(config)
.await
.map_err(|e| format!("保存代理配置失败: {e}"))?;
log::info!("代理服务器已启动: {}:{}", info.address, info.port);
Ok(info)
}
/// 停止代理服务器
pub async fn stop(&self) -> Result<(), String> {
if let Some(server) = self.server.write().await.take() {
server
.stop()
.await
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
log::info!("代理服务器已停止");
Ok(())
} else {
Err("代理服务器未运行".to_string())
}
}
/// 获取服务器状态
pub async fn get_status(&self) -> Result<ProxyStatus, String> {
if let Some(server) = self.server.read().await.as_ref() {
Ok(server.get_status().await)
} else {
// 服务器未运行时返回默认状态
Ok(ProxyStatus {
running: false,
..Default::default()
})
}
}
/// 获取代理配置
pub async fn get_config(&self) -> Result<ProxyConfig, String> {
self.db
.get_proxy_config()
.await
.map_err(|e| format!("获取代理配置失败: {e}"))
}
/// 更新代理配置
pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {
// 记录旧配置用于判定是否需要重启
let previous = self
.db
.get_proxy_config()
.await
.map_err(|e| format!("获取代理配置失败: {e}"))?;
// 保存到数据库(保持 enabled 状态不变)
let mut new_config = config.clone();
new_config.enabled = previous.enabled;
self.db
.update_proxy_config(new_config.clone())
.await
.map_err(|e| format!("保存代理配置失败: {e}"))?;
// 检查服务器当前状态
let mut server_guard = self.server.write().await;
if server_guard.is_none() {
return Ok(());
}
// 判断是否需要重启(地址或端口变更)
let require_restart = new_config.listen_address != previous.listen_address
|| new_config.listen_port != previous.listen_port;
if require_restart {
if let Some(server) = server_guard.take() {
server
.stop()
.await
.map_err(|e| format!("重启前停止代理服务器失败: {e}"))?;
}
let new_server = ProxyServer::new(new_config, self.db.clone());
new_server
.start()
.await
.map_err(|e| format!("重启代理服务器失败: {e}"))?;
*server_guard = Some(new_server);
log::info!("代理配置已更新,服务器已自动重启应用最新配置");
} else if let Some(server) = server_guard.as_ref() {
server.apply_runtime_config(&new_config).await;
log::info!("代理配置已实时应用,无需重启代理服务器");
}
Ok(())
}
/// 检查服务器是否正在运行
pub async fn is_running(&self) -> bool {
self.server.read().await.is_some()
}
}
+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)?;
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,14 +1,18 @@
use crate::database::Database;
use crate::services::ProxyService;
use std::sync::Arc;
/// 全局应用状态
pub struct AppState {
pub db: Arc<Database>,
pub proxy_service: ProxyService,
}
impl AppState {
/// 创建新的应用状态
pub fn new(db: Arc<Database>) -> Self {
Self { db }
let proxy_service = ProxyService::new(db.clone());
Self { db, proxy_service }
}
}
+300
View File
@@ -0,0 +1,300 @@
//! 托盘菜单管理模块
//!
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem};
use tauri::{Emitter, Manager};
use crate::app_config::AppType;
use crate::error::AppError;
use crate::store::AppState;
/// 托盘菜单文本(国际化)
#[derive(Clone, Copy)]
pub struct TrayTexts {
pub show_main: &'static str,
pub no_provider_hint: &'static str,
pub quit: &'static str,
}
impl TrayTexts {
pub fn from_language(language: &str) -> Self {
match language {
"en" => Self {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
quit: "Quit",
},
"ja" => Self {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
quit: "終了",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
quit: "退出",
},
}
}
}
/// 托盘应用分区配置
pub struct TrayAppSection {
pub app_type: AppType,
pub prefix: &'static str,
pub header_id: &'static str,
pub empty_id: &'static str,
pub header_label: &'static str,
pub log_name: &'static str,
}
pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
TrayAppSection {
app_type: AppType::Claude,
prefix: "claude_",
header_id: "claude_header",
empty_id: "claude_empty",
header_label: "─── Claude ───",
log_name: "Claude",
},
TrayAppSection {
app_type: AppType::Codex,
prefix: "codex_",
header_id: "codex_header",
empty_id: "codex_empty",
header_label: "─── Codex ───",
log_name: "Codex",
},
TrayAppSection {
app_type: AppType::Gemini,
prefix: "gemini_",
header_id: "gemini_header",
empty_id: "gemini_empty",
header_label: "─── Gemini ───",
log_name: "Gemini",
},
];
/// 添加供应商分区到菜单
fn append_provider_section<'a>(
app: &'a tauri::AppHandle,
mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>,
manager: Option<&crate::provider::ProviderManager>,
section: &TrayAppSection,
tray_texts: &TrayTexts,
) -> Result<MenuBuilder<'a, tauri::Wry, tauri::AppHandle<tauri::Wry>>, AppError> {
let Some(manager) = manager else {
return Ok(menu_builder);
};
let header = MenuItem::with_id(
app,
section.header_id,
section.header_label,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&header);
if manager.providers.is_empty() {
let empty_hint = MenuItem::with_id(
app,
section.empty_id,
tray_texts.no_provider_hint,
false,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?;
return Ok(menu_builder.item(&empty_hint));
}
let mut sorted_providers: Vec<_> = manager.providers.iter().collect();
sorted_providers.sort_by(|(_, a), (_, b)| {
match (a.sort_index, b.sort_index) {
(Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b),
(Some(_), None) => return std::cmp::Ordering::Less,
(None, Some(_)) => return std::cmp::Ordering::Greater,
_ => {}
}
match (a.created_at, b.created_at) {
(Some(time_a), Some(time_b)) => return time_a.cmp(&time_b),
(Some(_), None) => return std::cmp::Ordering::Greater,
(None, Some(_)) => return std::cmp::Ordering::Less,
_ => {}
}
a.name.cmp(&b.name)
});
for (id, provider) in sorted_providers {
let is_current = manager.current == *id;
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
is_current,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?;
menu_builder = menu_builder.item(&item);
}
Ok(menu_builder)
}
/// 处理供应商托盘事件
pub fn handle_provider_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
for section in TRAY_SECTIONS.iter() {
if let Some(provider_id) = event_id.strip_prefix(section.prefix) {
log::info!("切换到{}供应商: {provider_id}", section.log_name);
let app_handle = app.clone();
let provider_id = provider_id.to_string();
let app_type = section.app_type.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = switch_provider_internal(&app_handle, app_type, provider_id) {
log::error!("切换{}供应商失败: {e}", section.log_name);
}
});
return true;
}
}
false
}
/// 创建动态托盘菜单
pub fn create_tray_menu(
app: &tauri::AppHandle,
app_state: &AppState,
) -> Result<Menu<tauri::Wry>, AppError> {
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
let show_main_item =
MenuItem::with_id(app, "show_main", tray_texts.show_main, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
for section in TRAY_SECTIONS.iter() {
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
// 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID)
let current_id =
crate::settings::get_effective_current_provider(&app_state.db, &section.app_type)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,
current: current_id,
};
menu_builder =
append_provider_section(app, menu_builder, Some(&manager), section, &tray_texts)?;
}
// 分隔符和退出菜单
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
menu_builder = menu_builder.separator().item(&quit_item);
menu_builder
.build()
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
#[cfg(target_os = "macos")]
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
use tauri::ActivationPolicy;
let desired_policy = if dock_visible {
ActivationPolicy::Regular
} else {
ActivationPolicy::Accessory
};
if let Err(err) = app.set_dock_visibility(dock_visible) {
log::warn!("设置 Dock 显示状态失败: {err}");
}
if let Err(err) = app.set_activation_policy(desired_policy) {
log::warn!("设置激活策略失败: {err}");
}
}
/// 处理托盘菜单事件
pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
log::info!("处理托盘菜单事件: {event_id}");
match event_id {
"show_main" => {
if let Some(window) = app.get_webview_window("main") {
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "macos")]
{
apply_tray_policy(app, true);
}
}
}
"quit" => {
log::info!("退出应用");
app.exit(0);
}
_ => {
if handle_provider_tray_event(app, event_id) {
return;
}
log::warn!("未处理的菜单事件: {event_id}");
}
}
}
/// 内部切换供应商函数
pub fn switch_provider_internal(
app: &tauri::AppHandle,
app_type: AppType,
provider_id: String,
) -> Result<(), AppError> {
if let Some(app_state) = app.try_state::<AppState>() {
// 在使用前先保存需要的值
let app_type_str = app_type.as_str().to_string();
let provider_id_clone = provider_id.clone();
crate::commands::switch_provider(app_state.clone(), app_type_str.clone(), provider_id)
.map_err(AppError::Message)?;
// 切换成功后重新创建托盘菜单
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("更新托盘菜单失败: {e}");
}
}
}
// 发射事件到前端,通知供应商已切换
let event_data = serde_json::json!({
"appType": app_type_str,
"providerId": provider_id_clone
});
if let Err(e) = app.emit("provider-switched", event_data) {
log::error!("发射供应商切换事件失败: {e}");
}
}
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.8.0",
"version": "3.8.2",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"label": "main",
"titleBarStyle": "Visible"
}
]
}
}
+44 -34
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 {
@@ -290,6 +297,7 @@ function App() {
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp}
/>
);
case "mcp":
@@ -303,20 +311,26 @@ function App() {
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
default:
return (
<div className="mx-auto max-w-[56rem] px-6 space-y-4">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
<div className="space-y-4">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isProxyRunning={isProxyRunning}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
</div>
</div>
);
}
@@ -361,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}
>
@@ -470,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>
+1 -5
View File
@@ -40,11 +40,7 @@ export function ConfirmDialog({
}
}}
>
<DialogContent
className="max-w-sm"
zIndex="alert"
overlayClassName="bg-background/80"
>
<DialogContent className="max-w-sm" zIndex="alert">
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Provider, UsageScript } from "@/types";
import { Provider, UsageScript, UsageData } from "@/types";
import { usageApi, type AppId } from "@/lib/api";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
@@ -220,7 +220,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
.map((plan) => {
.map((plan: UsageData) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
return `${planInfo} ${t("usage.remaining")} ${plan.remaining} ${plan.unit}`;
})
+28 -22
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
@@ -224,16 +225,21 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("mcp.wizard.title")}</DialogTitle>
<DialogContent
className="max-w-2xl max-h-[90vh] flex flex-col"
zIndex="alert"
>
<DialogHeader className="space-y-3 border-b-0 bg-transparent pb-0">
<DialogTitle className="text-lg font-semibold">
{t("mcp.wizard.title")}
</DialogTitle>
</DialogHeader>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Hint */}
<div className="rounded-lg border border-border-active bg-blue-50 p-3 dark:bg-blue-900/20">
<p className="text-sm text-blue-800 dark:text-blue-200">
<div className="rounded-lg border border-border-default bg-gray-100/50 dark:bg-gray-800/50 p-3">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.wizard.hint")}
</p>
</div>
@@ -254,7 +260,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeStdio")}
@@ -268,7 +274,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeHttp")}
@@ -282,7 +288,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) =>
setWizardType(e.target.value as "stdio" | "http" | "sse")
}
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-border-default focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
className="w-4 h-4 accent-blue-500"
/>
<span className="text-sm text-gray-900 dark:text-gray-100">
{t("mcp.wizard.typeSse")}
@@ -296,13 +302,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
<input
<Input
type="text"
value={wizardTitle}
onChange={(e) => setWizardTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.form.titlePlaceholder")}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
className="font-mono"
/>
</div>
@@ -315,13 +321,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{t("mcp.wizard.command")}{" "}
<span className="text-red-500">*</span>
</label>
<input
<Input
type="text"
value={wizardCommand}
onChange={(e) => setWizardCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.commandPlaceholder")}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
className="font-mono"
/>
</div>
@@ -335,7 +341,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardArgs(e.target.value)}
placeholder={t("mcp.wizard.argsPlaceholder")}
rows={3}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
@@ -349,7 +355,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardEnv(e.target.value)}
placeholder={t("mcp.wizard.envPlaceholder")}
rows={3}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -364,13 +370,13 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
{t("mcp.wizard.url")}{" "}
<span className="text-red-500">*</span>
</label>
<input
<Input
type="text"
value={wizardUrl}
onChange={(e) => setWizardUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.urlPlaceholder")}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100"
className="font-mono"
/>
</div>
@@ -384,7 +390,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardHeaders(e.target.value)}
placeholder={t("mcp.wizard.headersPlaceholder")}
rows={3}
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:bg-gray-800 dark:text-gray-100 resize-y"
className="w-full rounded-md border border-border-default bg-white dark:bg-gray-800 px-3 py-2 text-sm font-mono text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 resize-y"
/>
</div>
</>
@@ -397,11 +403,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
wizardEnv ||
wizardUrl ||
wizardHeaders) && (
<div className="space-y-2 border-t border-border-default pt-4 ">
<div className="space-y-2 border-t border-border-default pt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("mcp.wizard.preview")}
</h3>
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<pre className="overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-3 text-xs font-mono text-gray-700 dark:text-gray-300">
{preview}
</pre>
</div>
@@ -409,11 +415,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
</div>
{/* Footer */}
<DialogFooter className="gap-3 pt-4">
<Button type="button" variant="ghost" onClick={handleClose}>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={handleClose}>
{t("common.cancel")}
</Button>
<Button type="button" variant="mcp" onClick={handleApply}>
<Button variant="mcp" onClick={handleApply}>
<Save className="h-4 w-4" />
{t("mcp.wizard.apply")}
</Button>
+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>
+64 -1
View File
@@ -1,24 +1,44 @@
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
import {
BarChart3,
Check,
Copy,
Edit,
Loader2,
Play,
TestTube2,
Trash2,
RotateCcw,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ProviderActionsProps {
isCurrent: boolean;
isTesting?: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onTest?: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
onResetCircuitBreaker?: () => void;
isProxyTarget?: boolean;
consecutiveFailures?: number;
}
export function ProviderActions({
isCurrent,
isTesting,
onSwitch,
onEdit,
onDuplicate,
onTest,
onConfigureUsage,
onDelete,
onResetCircuitBreaker,
isProxyTarget,
consecutiveFailures = 0,
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
@@ -70,6 +90,23 @@ export function ProviderActions({
<Copy className="h-4 w-4" />
</Button>
{onTest && (
<Button
size="icon"
variant="ghost"
onClick={onTest}
disabled={isTesting}
title={t("modelTest.testProvider", "测试模型")}
className={iconButtonClass}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
@@ -80,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"
+187 -14
View File
@@ -11,6 +11,15 @@ import { cn } from "@/lib/utils";
import { ProviderActions } from "@/components/providers/ProviderActions";
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;
@@ -28,6 +37,11 @@ interface ProviderCardProps {
onConfigureUsage: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
isTesting?: boolean;
isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表,用于计算开启后的优先级
dragHandleProps?: DragHandleProps;
}
@@ -76,10 +90,111 @@ export function ProviderCard({
onConfigureUsage,
onOpenWebsite,
onDuplicate,
onTest,
isTesting,
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: "未配置接口地址",
});
@@ -114,11 +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
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
: "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",
)}
@@ -141,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}
@@ -155,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
@@ -166,14 +302,42 @@ export function ProviderCard({
</span>
)}
<span
className={cn(
"rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500 dark:text-green-400 transition-opacity duration-200",
isCurrent ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
{t("provider.currentlyUsing")}
</span>
{/* 代理目标开关 - 仅在代理服务运行时显示 */}
{isProxyRunning && (
<div
className="flex items-center gap-2 ml-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
id={`proxy-target-switch-${provider.id}`}
checked={provider.isProxyTarget || false}
onCheckedChange={(checked) => {
handleSetProxyTarget(checked);
}}
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-green-600 dark:text-green-400 cursor-pointer"
>
{t("provider.proxyTarget", { defaultValue: "代理目标" })}
</Label>
)}
{!provider.isProxyTarget && (
<Label
htmlFor={`proxy-target-switch-${provider.id}`}
className="text-xs text-muted-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
{t("provider.setAsProxyTarget", {
defaultValue: "设为代理",
})}
</Label>
)}
</div>
)}
</div>
{displayUrl && (
@@ -210,11 +374,20 @@ export function ProviderCard({
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
<ProviderActions
isCurrent={isCurrent}
isTesting={isTesting}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
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>
);
}
+52
View File
@@ -5,10 +5,12 @@ 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 { useModelTest } from "@/hooks/useModelTest";
import { ProviderCard } from "@/components/providers/ProviderCard";
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
@@ -24,6 +26,7 @@ interface ProviderListProps {
onOpenWebsite: (url: string) => void;
onCreate?: () => void;
isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态
}
export function ProviderList({
@@ -38,12 +41,41 @@ export function ProviderList({
onOpenWebsite,
onCreate,
isLoading = false,
isProxyRunning = false, // 默认值为 false
}: ProviderListProps) {
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
providers,
appId,
);
// 模型测试
const { testProvider, isTesting } = useModelTest(appId);
// 计算代理目标的实际优先级映射 (P1, P2, P3...)
const proxyPriorityMap = useMemo(() => {
// 获取所有启用代理目标的供应商
const proxyTargets = sortedProviders.filter((p) => p.isProxyTarget);
// 按 sortIndex 排序
const sortedTargets = proxyTargets.sort((a, b) => {
const indexA = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
const indexB = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
return indexA - indexB;
});
// 创建优先级映射
const map = new Map<string, number>();
sortedTargets.forEach((provider, index) => {
map.set(provider.id, index + 1); // P1, P2, P3...
});
return map;
}, [sortedProviders]);
const handleTest = (provider: Provider) => {
testProvider(provider.id, provider.name);
};
if (isLoading) {
return (
<div className="space-y-3">
@@ -87,6 +119,11 @@ export function ProviderList({
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onTest={handleTest}
isTesting={isTesting(provider.id)}
isProxyRunning={isProxyRunning}
proxyPriority={proxyPriorityMap.get(provider.id)}
allProviders={sortedProviders}
/>
))}
</div>
@@ -105,6 +142,11 @@ interface SortableProviderCardProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
proxyPriority?: number; // 代理目标的实际优先级 (1, 2, 3...)
allProviders?: Provider[]; // 所有供应商列表
}
function SortableProviderCard({
@@ -117,6 +159,11 @@ function SortableProviderCard({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onTest,
isTesting,
isProxyRunning,
proxyPriority,
allProviders,
}: SortableProviderCardProps) {
const {
setNodeRef,
@@ -146,6 +193,11 @@ function SortableProviderCard({
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
}
onOpenWebsite={onOpenWebsite}
onTest={onTest}
isTesting={isTesting}
isProxyRunning={isProxyRunning}
proxyPriority={proxyPriority}
allProviders={allProviders}
dragHandleProps={{
attributes,
listeners,
@@ -41,7 +41,7 @@ export function useBaseUrlState({
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) {
if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
setBaseUrl(envUrl.trim());
}
} catch {
@@ -73,8 +73,7 @@ export function useBaseUrlState({
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.GOOGLE_GEMINI_BASE_URL;
const nextUrl =
typeof envUrl === "string" ? envUrl.trim().replace(/\/+$/, "") : "";
const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
if (nextUrl !== geminiBaseUrl) {
setGeminiBaseUrl(nextUrl);
setBaseUrl(nextUrl); // 也更新 baseUrl 用于 UI
@@ -87,7 +86,7 @@ export function useBaseUrlState({
// 处理 Claude Base URL 变化
const handleClaudeBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setBaseUrl(sanitized);
isUpdatingRef.current = true;
@@ -112,7 +111,7 @@ export function useBaseUrlState({
// 处理 Codex Base URL 变化
const handleCodexBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setCodexBaseUrl(sanitized);
if (!sanitized || !onCodexConfigChange) {
@@ -136,7 +135,7 @@ export function useBaseUrlState({
// 处理 Gemini Base URL 变化
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setGeminiBaseUrl(sanitized);
setBaseUrl(sanitized); // 也更新 baseUrl 用于 UI
isUpdatingRef.current = true;
@@ -162,7 +162,7 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
// 处理 Codex Base URL 变化
const handleCodexBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
const sanitized = url.trim();
setCodexBaseUrl(sanitized);
if (!sanitized) {
@@ -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>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { useState } from "react";
import { Activity, Clock, TrendingUp, Server, ListOrdered } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus";
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 } = useProxyStatus();
const [showSettings, setShowSettings] = useState(false);
// 获取所有三个应用类型的代理目标列表
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);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
};
return (
<>
<section className="space-y-6">
{isRunning && status ? (
<div className="space-y-6">
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div>
<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}
</code>
<Button
size="sm"
variant="outline"
onClick={() => {
navigator.clipboard.writeText(
`http://${status.address}:${status.port}`,
);
toast.success("地址已复制");
}}
>
</Button>
</div>
</div>
<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-border bg-background/60 px-2 py-1.5 text-xs"
>
<span className="text-muted-foreground">
{target.app_type}
</span>
<span
className="ml-2 font-medium truncate text-foreground"
title={target.provider_name}
>
{target.provider_name}
</span>
</div>
))}
</div>
) : status.current_provider ? (
<p className="text-sm text-muted-foreground">
Provider{" "}
<span className="font-medium text-foreground">
{status.current_provider}
</span>
</p>
) : (
<p className="text-sm text-yellow-600 dark:text-yellow-400">
Provider
</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">
<StatCard
icon={<Activity className="h-4 w-4" />}
label="活跃连接"
value={status.active_connections}
/>
<StatCard
icon={<TrendingUp className="h-4 w-4" />}
label="总请求数"
value={status.total_requests}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="成功率"
value={`${status.success_rate.toFixed(1)}%`}
variant={status.success_rate > 90 ? "success" : "warning"}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="运行时间"
value={formatUptime(status.uptime_seconds)}
/>
</div>
</div>
) : (
<div className="text-center py-10 text-muted-foreground">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Server className="h-8 w-8" />
</div>
<p className="text-base font-medium text-foreground mb-1">
</p>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
)}
</section>
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
</>
);
}
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
variant?: "default" | "success" | "warning";
}
function StatCard({ icon, label, value, variant = "default" }: StatCardProps) {
const variantStyles = {
default: "",
success: "border-green-500/40 bg-green-500/5",
warning: "border-yellow-500/40 bg-yellow-500/5",
};
return (
<div
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">{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>
);
}
@@ -0,0 +1,397 @@
/**
* 代理服务设置对话框
*/
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useProxyConfig } from "@/hooks/useProxyConfig";
import { useEffect, useMemo } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { ProxyConfig } from "@/types/proxy";
// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理)
type ProxyConfigForm = Omit<ProxyConfig, "enabled">;
const createProxyConfigSchema = (t: TFunction) => {
const requestTimeoutSchema = z
.number()
.min(
0,
t("proxy.settings.validation.timeoutNonNegative", {
defaultValue: "超时时间不能为负数",
}),
)
.max(
600,
t("proxy.settings.validation.timeoutMax", {
defaultValue: "超时时间最多600秒",
}),
)
.refine((value) => value === 0 || value >= 10, {
message: t("proxy.settings.validation.timeoutRange", {
defaultValue: "请输入 0 或 10-600 之间的数值",
}),
});
return z.object({
listen_address: z.string().regex(
/^(\d{1,3}\.){3}\d{1,3}$/,
t("proxy.settings.validation.addressInvalid", {
defaultValue: "请输入有效的IP地址",
}),
),
listen_port: z
.number()
.min(
1024,
t("proxy.settings.validation.portMin", {
defaultValue: "端口必须大于1024",
}),
)
.max(
65535,
t("proxy.settings.validation.portMax", {
defaultValue: "端口必须小于65535",
}),
),
max_retries: z
.number()
.min(
0,
t("proxy.settings.validation.retryMin", {
defaultValue: "重试次数不能为负",
}),
)
.max(
10,
t("proxy.settings.validation.retryMax", {
defaultValue: "重试次数不能超过10",
}),
),
request_timeout: requestTimeoutSchema,
enable_logging: z.boolean(),
});
};
interface ProxySettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProxySettingsDialog({
open,
onOpenChange,
}: ProxySettingsDialogProps) {
const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
const { t } = useTranslation();
const schema = useMemo(() => createProxyConfigSchema(t), [t]);
const closePanel = () => onOpenChange(false);
const form = useForm<ProxyConfigForm>({
resolver: zodResolver(schema),
defaultValues: {
listen_address: "127.0.0.1",
listen_port: 5000,
max_retries: 3,
request_timeout: 300,
enable_logging: true,
},
});
// 当配置加载完成后更新表单
useEffect(() => {
if (config) {
form.reset({
...config,
});
}
}, [config, form]);
const onSubmit = async (data: ProxyConfigForm) => {
try {
// 添加 enabled 字段(从当前配置中获取,保持不变)
const configToSave: ProxyConfig = {
...data,
enabled: config?.enabled ?? true,
};
await updateConfig(configToSave);
closePanel();
} catch (error) {
console.error("Save config failed:", error);
}
};
const formId = "proxy-settings-form";
return (
<FullScreenPanel
isOpen={open}
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
onClose={closePanel}
footer={
<>
<Button
type="button"
variant="outline"
onClick={closePanel}
disabled={isUpdating}
>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="submit"
form={formId}
disabled={isUpdating || isLoading}
>
{isUpdating
? t("common.saving", { defaultValue: "保存中..." })
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
</Button>
</>
}
>
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("proxy.settings.description", {
defaultValue:
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
})}
</p>
<Alert className="border-emerald-500/40 bg-emerald-500/10">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{t("proxy.settings.alert.autoApply", {
defaultValue:
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
})}
</AlertDescription>
</Alert>
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.basic.title", {
defaultValue: "基础设置",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.basic.description", {
defaultValue: "配置代理服务监听的地址与端口。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="listen_address"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenAddress.label", {
defaultValue: "监听地址",
})}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"proxy.settings.fields.listenAddress.placeholder",
{ defaultValue: "127.0.0.1" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenAddress.description", {
defaultValue:
"代理服务器监听的 IP 地址(推荐 127.0.0.1",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listen_port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.listenPort.label", {
defaultValue: "监听端口",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.listenPort.placeholder",
{ defaultValue: "5000" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.listenPort.description", {
defaultValue:
"代理服务器监听的端口号(1024 ~ 65535",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</section>
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
<div>
<h3 className="text-base font-semibold text-foreground">
{t("proxy.settings.advanced.title", {
defaultValue: "高级参数",
})}
</h3>
<p className="text-sm text-muted-foreground">
{t("proxy.settings.advanced.description", {
defaultValue: "控制请求的稳定性和日志记录。",
})}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.maxRetries.label", {
defaultValue: "最大重试次数",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.maxRetries.placeholder",
{ defaultValue: "3" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.maxRetries.description", {
defaultValue: "请求失败时的重试次数(0 ~ 10)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="request_timeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("proxy.settings.fields.requestTimeout.label", {
defaultValue: "请求超时(秒)",
})}
</FormLabel>
<FormControl>
<Input
type="number"
inputMode="numeric"
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
placeholder={t(
"proxy.settings.fields.requestTimeout.placeholder",
{ defaultValue: "0(不限)或 300" },
)}
disabled={isLoading}
/>
</FormControl>
<FormDescription>
{t("proxy.settings.fields.requestTimeout.description", {
defaultValue:
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="enable_logging"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
<div className="space-y-1">
<FormLabel>
{t("proxy.settings.fields.enableLogging.label", {
defaultValue: "启用日志记录",
})}
</FormLabel>
<FormDescription>
{t("proxy.settings.fields.enableLogging.description", {
defaultValue: "记录所有代理请求,便于排查问题",
})}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</section>
</form>
</Form>
</div>
</FullScreenPanel>
);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* 代理功能组件导出
*/
export { ProxyPanel } from "./ProxyPanel";
export { ProxySettingsDialog } from "./ProxySettingsDialog";
+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
+259 -39
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";
@@ -17,10 +31,18 @@ import { WindowSettings } from "@/components/settings/WindowSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
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;
@@ -150,8 +172,28 @@ 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)] px-6">
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -162,17 +204,20 @@ export function SettingsPage({
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3 mb-6 glass rounded-xl">
<TabsList className="grid w-full grid-cols-4 mb-6 glass rounded-lg">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="usage">
{t("usage.title", "使用统计")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto pr-2">
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<>
@@ -191,57 +236,235 @@ 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}
/>
<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">
<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>
<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>
<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>
<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>
<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>
<TabsContent value="about" className="mt-0">
<AboutSection isPortable={isPortable} />
</TabsContent>
<TabsContent value="usage" className="mt-0">
<UsageDashboard />
</TabsContent>
</div>
</Tabs>
)}
@@ -250,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>
@@ -266,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}
+3 -3
View File
@@ -90,7 +90,7 @@ export function RepoManagerPanel({
onClose={onClose}
>
{/* 添加仓库表单 */}
<div className="space-y-4 glass rounded-xl p-6 border border-white/10">
<div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground">
</h3>
@@ -139,7 +139,7 @@ export function RepoManagerPanel({
{t("skills.repo.list")}
</h3>
{repos.length === 0 ? (
<div className="text-center py-12 glass rounded-xl border border-white/10">
<div className="text-center py-12 glass-card rounded-xl">
<p className="text-sm text-muted-foreground">
{t("skills.repo.empty")}
</p>
@@ -149,7 +149,7 @@ export function RepoManagerPanel({
{repos.map((repo) => (
<div
key={`${repo.owner}/${repo.name}`}
className="flex items-center justify-between rounded-xl border border-white/10 bg-gray-900/40 px-4 py-3"
className="flex items-center justify-between glass-card rounded-xl px-4 py-3"
>
<div>
<div className="text-sm font-medium text-foreground">
+2 -2
View File
@@ -57,7 +57,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return (
<Card className="glass flex flex-col h-full border border-white/10 bg-gray-900/40 transition-all duration-300 hover:bg-gray-900/60 hover:border-white/20 hover:shadow-lg group relative overflow-hidden">
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
@@ -96,7 +96,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
{skill.description || t("skills.noDescription")}
</p>
</CardContent>
<CardFooter className="flex gap-2 pt-3 border-t border-white/5 relative z-10">
<CardFooter className="flex gap-2 pt-3 border-t border-border/50 relative z-10">
{skill.readmeUrl && (
<Button
variant="ghost"
+6 -8
View File
@@ -205,12 +205,10 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
}, [skills, searchQuery, filterStatus]);
return (
<div className="flex flex-col h-full min-h-0 bg-background/50">
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */}
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 min-h-0 overflow-y-auto animate-fade-in">
<div className="mx-auto max-w-[56rem] px-6 py-4">
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -263,19 +261,19 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
<SelectContent className="bg-card text-foreground shadow-lg">
<SelectItem
value="all"
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.all")}
</SelectItem>
<SelectItem
value="installed"
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.installed")}
</SelectItem>
<SelectItem
value="uninstalled"
className="text-left pr-3 [&[data-state=checked]>span]:hidden"
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
>
{t("skills.filter.uninstalled")}
</SelectItem>
+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 };

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