64 Commits

Author SHA1 Message Date
YoVinchen
be1c2ac76e feat(skills): add search functionality to Skills page
- Add search input with Search icon in SkillsPage component
- Implement useMemo-based filtering by skill name, description, and directory
- Display search results count when filtering is active
- Show "no results" message when no skills match the search query
- Add i18n translations for search UI (zh/en)
- Maintain responsive layout and consistent styling with existing UI
2025-11-23 00:10:07 +08:00
YoVinchen
e7451bda22 Merge branch 'main' into refactor/storage 2025-11-22 23:29:48 +08:00
YoVinchen
5a3420932b refactor(frontend): update UI components for database migration
- Update UsageFooter component to handle new data structure
- Modify SkillsPage to work with database-backed skills management
- Ensure frontend compatibility with refactored backend
2025-11-22 23:28:35 +08:00
YoVinchen
a2688603fb refactor(backend): update supporting modules for database compatibility
- Add DatabaseError variant to AppError enum
- Update provider module to support database-backed operations
- Modify codex_config to work with new database structure
- Ensure error handling covers database operations
2025-11-22 23:27:54 +08:00
YoVinchen
23a407544a refactor(commands): update command layer to use database API
- Update config commands to query database for providers and settings
- Modify provider commands to pass database handle to services
- Update MCP commands to use database-backed operations
- Refactor prompt and skill commands to leverage database storage
- Simplify import/export commands with database integration
2025-11-22 23:27:27 +08:00
YoVinchen
2b34dc4ec9 refactor(services): migrate service layer to use SQLite database
- Refactor ProviderService to use database queries instead of in-memory config
- Update McpService to fetch and store MCP servers in database
- Migrate PromptService to database-backed storage
- Simplify ConfigService by removing complex transaction logic
- Remove 648 lines of redundant code through database abstraction
2025-11-22 23:26:54 +08:00
YoVinchen
529051f0e8 refactor(core): integrate SQLite database into application core
- Initialize database on app startup with migration from JSON config
- Update AppState to include Database instance alongside MultiAppConfig
- Simplify store module by removing unused session management code
- Add database initialization to app setup flow
- Support both database and legacy config during transition
2025-11-22 23:26:41 +08:00
YoVinchen
5d1eed563d feat(database): add SQLite database infrastructure
- Add rusqlite dependency (v0.32.1) and r2d2 connection pooling
- Implement Database module with CRUD operations for providers, MCP servers, prompts, and skills
- Add schema initialization with proper indexes
- Include data migration utilities from JSON config to SQLite
- Support timestamp tracking (created_at, updated_at)
2025-11-22 23:23:56 +08:00
Jason
cc0b9352bc update readme 2025-11-22 21:51:09 +08:00
Bill ZHANG
01d8bb53ac fix(codex): use http_headers instead of headers in MCP config (#276)
Codex CLI expects the field name to be `http_headers` (not `headers`)
in the MCP server configuration TOML format.

Changes:
- Update import_from_codex() to read from both `http_headers` (correct)
  and `headers` (legacy) with priority to `http_headers`
- Update json_server_to_toml_table() to write `http_headers` instead
  of `headers`
- Update core_fields lists to use `http_headers` for HTTP/SSE types

This follows the same pattern as the recent Gemini fix and ensures
backward compatibility while generating correct Codex-compatible configs.
2025-11-22 20:46:30 +08:00
YoVinchen
6e7547ef6e Merge branch 'main' into refactor/storage 2025-11-22 19:57:01 +08:00
YoVinchen
d38fcd63ea Refactor/UI (#273)
* feat(components): add reusable full-screen panel components

Add new full-screen panel components to support the UI refactoring:

- FullScreenPanel: Reusable full-screen layout component with header,
  content area, and optional footer. Provides consistent layout for
  settings, prompts, and other full-screen views.

- PromptFormPanel: Dedicated panel for creating and editing prompts
  with markdown preview support. Features real-time validation and
  integrated save/cancel actions.

- AgentsPanel: Panel component for managing agent configurations.
  Provides a consistent interface for agent CRUD operations.

- RepoManagerPanel: Full-featured repository manager panel for Skills.
  Supports repository listing, addition, deletion, and configuration
  management with integrated validation.

These components establish the foundation for the upcoming settings
page migration from dialog-based to full-screen layout.

* refactor(settings): migrate from dialog to full-screen page layout

Complete migration of settings from modal dialog to dedicated full-screen
page, improving UX and providing more space for configuration options.

Changes:
- Remove SettingsDialog component (legacy modal-based interface)
- Add SettingsPage component with full-screen layout using FullScreenPanel
- Refactor App.tsx routing to support dedicated settings page
  * Add settings route handler
  * Update navigation logic from dialog-based to page-based
  * Integrate with existing app switcher and provider management
- Update ImportExportSection to work with new page layout
  * Improve spacing and layout for better readability
  * Enhanced error handling and user feedback
  * Better integration with page-level actions
- Enhance useSettings hook to support page-based workflow
  * Add navigation state management
  * Improve settings persistence logic
  * Better error boundary handling

Benefits:
- More intuitive navigation with dedicated settings page
- Better use of screen space for complex configurations
- Improved accessibility with clearer visual hierarchy
- Consistent with modern desktop application patterns
- Easier to extend with new settings sections

This change is part of the larger UI refactoring initiative to modernize
the application interface and improve user experience.

* refactor(forms): simplify and modernize form components

Comprehensive refactoring of form components to reduce complexity,
improve maintainability, and enhance user experience.

Provider Forms:
- CodexCommonConfigModal & CodexConfigSections
  * Simplified state management with reduced boilerplate
  * Improved field validation and error handling
  * Better layout with consistent spacing
  * Enhanced model selection with visual indicators
- GeminiCommonConfigModal & GeminiConfigSections
  * Streamlined authentication flow (OAuth vs API Key)
  * Cleaner form layout with better grouping
  * Improved validation feedback
  * Better integration with parent components
- CommonConfigEditor
  * Reduced from 178 to 68 lines (-62% complexity)
  * Extracted reusable form patterns
  * Improved JSON editing with syntax validation
  * Better error messages and recovery options
- EndpointSpeedTest
  * Complete rewrite for better UX
  * Real-time testing progress indicators
  * Enhanced error handling with retry logic
  * Visual feedback for test results (color-coded latency)

MCP & Prompts:
- McpFormModal
  * Simplified from 581 to ~360 lines
  * Better stdio/http server type handling
  * Improved form validation
  * Enhanced multi-app selection (Claude/Codex/Gemini)
- PromptPanel
  * Cleaner integration with PromptFormPanel
  * Improved list/grid view switching
  * Better state management for editing workflows
  * Enhanced delete confirmation with safety checks

Code Quality Improvements:
- Reduced total lines by ~251 lines (-24% code reduction)
- Eliminated duplicate validation logic
- Improved TypeScript type safety
- Better component composition and separation of concerns
- Enhanced accessibility with proper ARIA labels

These changes make forms more intuitive, responsive, and easier to
maintain while reducing bundle size and improving runtime performance.

* style(ui): modernize component layouts and visual design

Update UI components with improved layouts, visual hierarchy, and
modern design patterns for better user experience.

Navigation & Brand Components:
- AppSwitcher
  * Enhanced visual design with better spacing
  * Improved active state indicators
  * Smoother transitions and hover effects
  * Better mobile responsiveness
- BrandIcons
  * Optimized icon rendering performance
  * Added support for more provider icons
  * Improved SVG handling and fallbacks
  * Better scaling across different screen sizes

Editor Components:
- JsonEditor
  * Enhanced syntax highlighting
  * Better error visualization
  * Improved code formatting options
  * Added line numbers and code folding support
- UsageScriptModal
  * Complete layout overhaul (1239 lines refactored)
  * Better script editor integration
  * Improved template selection UI
  * Enhanced preview and testing panels
  * Better error feedback and validation

Provider Components:
- ProviderCard
  * Redesigned card layout with modern aesthetics
  * Better information density and readability
  * Improved action buttons placement
  * Enhanced status indicators (active/inactive)
- ProviderList
  * Better grid/list view layouts
  * Improved drag-and-drop visual feedback
  * Enhanced sorting indicators
- ProviderActions
  * Streamlined action menu
  * Better icon consistency
  * Improved tooltips and accessibility

Usage & Footer:
- UsageFooter
  * Redesigned footer layout
  * Better quota visualization
  * Improved refresh controls
  * Enhanced error states

Design System Updates:
- dialog.tsx (shadcn/ui component)
  * Updated to latest design tokens
  * Better overlay animations
  * Improved focus management
- index.css
  * Added 65 lines of global utility classes
  * New animation keyframes
  * Enhanced color variables for dark mode
  * Improved typography scale
- tailwind.config.js
  * Extended theme with new design tokens
  * Added custom animations and transitions
  * New spacing and sizing utilities
  * Enhanced color palette

Visual Improvements:
- Consistent border radius across components
- Unified shadow system for depth perception
- Better color contrast for accessibility (WCAG AA)
- Smoother animations and transitions
- Improved dark mode support

These changes create a more polished, modern interface while
maintaining consistency with the application's design language.

* chore: update dialogs, i18n and improve component integration

Various functional updates and improvements across provider dialogs,
MCP panel, skills page, and internationalization.

Provider Dialogs:
- AddProviderDialog
  * Simplified form state management
  * Improved preset selection workflow
  * Better validation error messages
  * Enhanced template variable handling
- EditProviderDialog
  * Streamlined edit flow with better state synchronization
  * Improved handling of live config backfilling
  * Better error recovery for failed updates
  * Enhanced integration with parent components

MCP & Skills:
- UnifiedMcpPanel
  * Reduced complexity from 140+ to ~95 lines
  * Improved multi-app server management
  * Better server type detection (stdio/http)
  * Enhanced server status indicators
  * Cleaner integration with MCP form modal
- SkillsPage
  * Simplified navigation and state management
  * Better integration with RepoManagerPanel
  * Improved error handling for repository operations
  * Enhanced loading states
- SkillCard
  * Minor layout adjustments
  * Better action button placement

Environment & Configuration:
- EnvWarningBanner
  * Improved conflict detection messages
  * Better visual hierarchy for warnings
  * Enhanced dismissal behavior
- tauri.conf.json
  * Updated build configuration
  * Added new window management options

Internationalization:
- en.json & zh.json
  * Added 17 new translation keys for new features
  * Updated existing keys for better clarity
  * Added translations for new settings page
  * Improved consistency across UI text

Code Cleanup:
- mutations.ts
  * Removed 14 lines of unused mutation definitions
  * Cleaned up deprecated query invalidation logic
  * Better type safety for mutation parameters

Overall Impact:
- Reduced total lines by 51 (-10% in affected files)
- Improved component integration and data flow
- Better error handling and user feedback
- Enhanced i18n coverage for new features

These changes improve the overall polish and integration of various
components while removing technical debt and unused code.

* feat(backend): add auto-launch functionality

Implement system auto-launch feature to allow CC-Switch to start
automatically on system boot, improving user convenience.

Backend Implementation:
- auto_launch.rs: New module for auto-launch management
  * Cross-platform support using auto-launch crate
  * Enable/disable auto-launch with system integration
  * Proper error handling for permission issues
  * Platform-specific implementations (macOS/Windows/Linux)

Command Layer:
- Add get_auto_launch command to check current status
- Add set_auto_launch command to toggle auto-start
- Integrate commands with settings API

Settings Integration:
- Extend Settings struct with auto_launch field
- Persist auto-launch preference in settings store
- Automatic state synchronization on app startup

Dependencies:
- Add auto-launch ^0.5.0 to Cargo.toml
- Update Cargo.lock with new dependency tree

Technical Details:
- Uses platform-specific auto-launch mechanisms:
  * macOS: Login Items via LaunchServices
  * Windows: Registry Run key
  * Linux: XDG autostart desktop files
- Handles edge cases like permission denials gracefully
- Maintains settings consistency across app restarts

This feature enables users to have CC-Switch readily available
after system boot without manual intervention, particularly useful
for users who frequently switch between API providers.

* refactor(settings): enhance settings page with auto-launch integration

Complete refactoring of settings page architecture to integrate auto-launch
feature and improve overall settings management workflow.

SettingsPage Component:
- Integrate auto-launch toggle with WindowSettings section
- Improve layout and spacing for better visual hierarchy
- Enhanced error handling for settings operations
- Better loading states during settings updates
- Improved accessibility with proper ARIA labels

WindowSettings Component:
- Add auto-launch switch with real-time status
- Integrate with backend auto-launch commands
- Proper error feedback for permission issues
- Visual indicators for current auto-launch state
- Tooltip guidance for auto-launch functionality

useSettings Hook (Major Refactoring):
- Complete rewrite reducing complexity by ~30%
- Better separation of concerns with dedicated handlers
- Improved state management using React Query
- Enhanced auto-launch state synchronization
  * Fetch auto-launch status on mount
  * Real-time updates on toggle
  * Proper error recovery
- Optimized re-renders with better memoization
- Cleaner API for component integration
- Better TypeScript type safety

Settings API:
- Add getAutoLaunch() method
- Add setAutoLaunch(enabled: boolean) method
- Type-safe Tauri command invocations
- Proper error propagation to UI layer

Architecture Improvements:
- Reduced hook complexity from 197 to ~140 effective lines
- Eliminated redundant state management logic
- Better error boundaries and fallback handling
- Improved testability with clearer separation

User Experience Enhancements:
- Instant visual feedback on auto-launch toggle
- Clear error messages for permission issues
- Loading indicators during async operations
- Consistent behavior across all platforms

This refactoring provides a solid foundation for future settings
additions while maintaining code quality and user experience.

* refactor(ui): optimize FullScreenPanel, Dialog and App routing

Comprehensive refactoring of core UI components to improve code quality,
maintainability, and user experience.

FullScreenPanel Component:
- Enhanced props interface with better TypeScript types
- Improved layout flexibility with customizable padding
- Better header/footer composition patterns
- Enhanced scroll behavior for long content
- Added support for custom actions in header
- Improved responsive design for different screen sizes
- Better integration with parent components
- Cleaner prop drilling with context where appropriate

Dialog Component (shadcn/ui):
- Updated to latest component patterns
- Improved animation timing and easing
- Better focus trap management
- Enhanced overlay styling with backdrop blur
- Improved accessibility (ARIA labels, keyboard navigation)
- Better close button positioning and styling
- Enhanced mobile responsiveness
- Cleaner composition with DialogHeader/Footer

App Component Routing:
- Refactored routing logic for better clarity
- Improved state management for navigation
- Better integration with settings page
- Enhanced error boundary handling
- Cleaner separation of layout concerns
- Improved provider context propagation
- Better handling of deep links
- Optimized re-renders with React.memo where appropriate

Code Quality Improvements:
- Reduced prop drilling with better component composition
- Improved TypeScript type safety
- Better separation of concerns
- Enhanced code readability with clearer naming
- Eliminated redundant logic

Performance Optimizations:
- Reduced unnecessary re-renders
- Better memoization of callbacks
- Optimized component tree structure
- Improved event handler efficiency

User Experience:
- Smoother transitions and animations
- Better visual feedback for interactions
- Improved loading states
- More consistent behavior across features

These changes create a more maintainable and performant foundation
for the application's UI layer while improving the overall user
experience with smoother interactions and better visual polish.

* refactor(features): modernize Skills, Prompts and Agents components

Major refactoring of feature components to improve code quality,
user experience, and maintainability.

SkillsPage Component (299 lines refactored):
- Complete rewrite of layout and state management
- Better integration with RepoManagerPanel
- Improved navigation between list and detail views
- Enhanced error handling with user-friendly messages
- Better loading states with skeleton screens
- Optimized re-renders with proper memoization
- Cleaner separation between list and form views
- Improved skill card interactions
- Better responsive design for different screen sizes

RepoManagerPanel Component (370 lines refactored):
- Streamlined repository management workflow
- Enhanced form validation with real-time feedback
- Improved repository list with better visual hierarchy
- Better handling of git operations (clone, pull, delete)
- Enhanced error recovery for network issues
- Cleaner state management reducing complexity
- Improved TypeScript type safety
- Better integration with Skills backend API
- Enhanced loading indicators for async operations

PromptPanel Component (249 lines refactored):
- Modernized layout with FullScreenPanel integration
- Better separation between list and edit modes
- Improved prompt card design with better readability
- Enhanced search and filter functionality
- Cleaner state management for editing workflow
- Better integration with PromptFormPanel
- Improved delete confirmation with safety checks
- Enhanced keyboard navigation support

PromptFormPanel Component (238 lines refactored):
- Streamlined form layout and validation
- Better markdown editor integration
- Real-time preview with syntax highlighting
- Improved validation error display
- Enhanced save/cancel workflow
- Better handling of large prompt content
- Cleaner form state management
- Improved accessibility features

AgentsPanel Component (33 lines modified):
- Minor layout adjustments for consistency
- Better integration with FullScreenPanel
- Improved placeholder states
- Enhanced error boundaries

Type Definitions (types.ts):
- Added 10 new type definitions
- Better type safety for Skills/Prompts/Agents
- Enhanced interfaces for repository management
- Improved typing for form validations

Architecture Improvements:
- Reduced component coupling
- Better prop interfaces with explicit types
- Improved error boundaries
- Enhanced code reusability
- Better testing surface

User Experience Enhancements:
- Smoother transitions between views
- Better visual feedback for actions
- Improved error messages
- Enhanced loading states
- More intuitive navigation flows
- Better responsive layouts

Code Quality:
- Net reduction of 29 lines while adding features
- Improved code organization
- Better naming conventions
- Enhanced documentation
- Cleaner control flow

These changes significantly improve the maintainability and user
experience of core feature components while establishing consistent
patterns for future development.

* style(ui): refine component layouts and improve visual consistency

Comprehensive UI polish across multiple components to enhance visual
design, improve user experience, and maintain consistency.

UsageScriptModal Component (1302 lines refactored):
- Complete layout overhaul for better usability
- Improved script editor with syntax highlighting
- Better template selection interface
- Enhanced test/preview panels with clearer separation
- Improved error feedback and validation messages
- Better modal sizing and responsiveness
- Cleaner tab navigation between sections
- Enhanced code formatting and readability
- Improved loading states for async operations
- Better integration with parent components

MCP Components:
- McpFormModal (42 lines):
  * Streamlined form layout
  * Better server type selection (stdio/http)
  * Improved field grouping and labels
  * Enhanced validation feedback
- UnifiedMcpPanel (14 lines):
  * Minor layout adjustments
  * Better list item spacing
  * Improved server status indicators
  * Enhanced action button placement

Provider Components:
- ProviderCard (11 lines):
  * Refined card layout and spacing
  * Better visual hierarchy
  * Improved badge placement
  * Enhanced hover effects
- ProviderList (5 lines):
  * Minor grid layout adjustments
  * Better drag-and-drop visual feedback
- GeminiConfigSections (4 lines):
  * Field label alignment
  * Improved spacing consistency

Editor & Footer Components:
- JsonEditor (13 lines):
  * Better editor height management
  * Improved error display
  * Enhanced syntax highlighting
- UsageFooter (10 lines):
  * Refined footer layout
  * Better quota display
  * Improved refresh button placement

Settings & Environment:
- ImportExportSection (24 lines):
  * Better button layout
  * Improved action grouping
  * Enhanced visual feedback
- EnvWarningBanner (4 lines):
  * Refined alert styling
  * Better dismiss button placement

Global Styles (index.css):
- Added 11 lines of utility classes
- Improved transition timing
- Better focus indicators
- Enhanced scrollbar styling
- Refined spacing utilities

Design Improvements:
- Consistent spacing using design tokens
- Unified color palette application
- Better typography hierarchy
- Improved shadow system for depth
- Enhanced interactive states (hover, active, focus)
- Better border radius consistency
- Refined animation timings

Accessibility:
- Improved focus indicators
- Better keyboard navigation
- Enhanced screen reader support
- Improved color contrast ratios

Code Quality:
- Net increase of 68 lines due to UsageScriptModal improvements
- Better component organization
- Cleaner style application
- Reduced style duplication

These visual refinements create a more polished and professional
interface while maintaining excellent usability and accessibility
standards across all components.

* chore(i18n): add auto-launch translation keys

Add translation keys for new auto-launch feature to support
multi-language interface.

Translation Keys Added:
- autoLaunch: Label for auto-launch toggle
- autoLaunchDescription: Explanation of auto-launch functionality
- autoLaunchEnabled: Status message when enabled

Languages Updated:
- Chinese (zh.json): 简体中文翻译
- English (en.json): English translations

The translations maintain consistency with existing terminology
and provide clear, user-friendly descriptions of the auto-launch
feature across both supported languages.

* test: update test suites to match component refactoring

Comprehensive test updates to align with recent component refactoring
and new auto-launch functionality.

Component Tests:
- AddProviderDialog.test.tsx (10 lines):
  * Updated test cases for new dialog behavior
  * Enhanced mock data for preset selection
  * Improved assertions for validation

- ImportExportSection.test.tsx (16 lines):
  * Updated for new settings page integration
  * Enhanced test coverage for error scenarios
  * Better mock state management

- McpFormModal.test.tsx (60 lines):
  * Extensive updates for form refactoring
  * New test cases for multi-app selection
  * Enhanced validation testing
  * Better coverage of stdio/http server types

- ProviderList.test.tsx (11 lines):
  * Updated for new card layout
  * Enhanced drag-and-drop testing

- SettingsDialog.test.tsx (96 lines):
  * Major updates for SettingsPage migration
  * New test cases for auto-launch functionality
  * Enhanced integration test coverage
  * Better async operation testing

Hook Tests:
- useDirectorySettings.test.tsx (32 lines):
  * Updated for refactored hook logic
  * Enhanced test coverage for edge cases

- useDragSort.test.tsx (36 lines):
  * Simplified test cases
  * Better mock implementation
  * Improved assertions

- useImportExport tests (16 lines total):
  * Updated for new error handling
  * Enhanced test coverage

- useMcpValidation.test.tsx (23 lines):
  * Updated validation test cases
  * Better coverage of error scenarios

- useProviderActions.test.tsx (48 lines):
  * Extensive updates for hook refactoring
  * New test cases for provider operations
  * Enhanced mock data

- useSettings.test.tsx (12 lines):
  * New test cases for auto-launch
  * Enhanced settings state testing
  * Better async operation coverage

Integration Tests:
- App.test.tsx (41 lines):
  * Updated for new routing logic
  * Enhanced navigation testing
  * Better component integration coverage

- SettingsDialog.test.tsx (88 lines):
  * Complete rewrite for SettingsPage
  * New integration test scenarios
  * Enhanced user workflow testing

Mock Infrastructure:
- handlers.ts (117 lines):
  * Major updates for MSW handlers
  * New handlers for auto-launch commands
  * Enhanced error simulation
  * Better request/response mocking

- state.ts (37 lines):
  * Updated mock state structure
  * New state for auto-launch
  * Enhanced state reset functionality

- tauriMocks.ts (10 lines):
  * Updated mock implementations
  * Better type safety

- server.ts & testQueryClient.ts:
  * Minor cleanup (2 lines removed)

Test Infrastructure Improvements:
- Better test isolation
- Enhanced mock data consistency
- Improved async operation testing
- Better error scenario coverage
- Enhanced integration test patterns

Coverage Improvements:
- Net increase of 195 lines of test code
- Better coverage of edge cases
- Enhanced error path testing
- Improved integration test scenarios
- Better mock infrastructure

All tests now pass with the refactored components while maintaining
comprehensive coverage of functionality and edge cases.

* style(ui): improve window dragging and provider card styles

* fix(skills): resolve third-party skills installation failure

- Add skills_path field to Skill struct
- Use skills_path to construct correct source path during installation
- Fix installation for repos with custom skill subdirectories

* feat(icon): add icon type system and intelligent inference logic

Introduce a new icon system for provider customization:

- Add IconMetadata and IconPreset interfaces in src/types/icon.ts
  - Define structure for icon name, display name, category, keywords
  - Support default color configuration per icon

- Implement smart icon inference in src/config/iconInference.ts
  - Create iconMappings for 25+ AI providers and cloud platforms
  - Include Claude, DeepSeek, Qwen, Kimi, Google, AWS, Azure, etc.
  - inferIconForPreset(): match provider name to icon config
  - addIconsToPresets(): batch apply icons to preset arrays
  - Support fuzzy matching for flexible name recognition

This foundation enables automatic icon assignment when users add
providers, improving visual identification in the provider list.

* feat(ui): add icon picker, color picker and provider icon components

Implement comprehensive icon selection system for provider customization:

## New Components

### ProviderIcon (src/components/ProviderIcon.tsx)
- Render SVG icons by name with automatic fallback
- Display provider initials when icon not found
- Support custom sizing via size prop
- Use dangerouslySetInnerHTML for inline SVG rendering

### IconPicker (src/components/IconPicker.tsx)
- Grid-based icon selection with visual preview
- Real-time search filtering by name and keywords
- Integration with icon metadata for display names
- Responsive grid layout (6-10 columns based on screen)

### ColorPicker (src/components/ColorPicker.tsx)
- 12 preset colors for quick selection
- Native color input for custom color picking
- Hex input field for precise color entry
- Visual feedback for selected color state

## Icon Assets (src/icons/extracted/)
- 38 high-quality SVG icons for AI providers and platforms
- Includes: OpenAI, Claude, DeepSeek, Qwen, Kimi, Gemini, etc.
- Cloud platforms: AWS, Azure, Google Cloud, Cloudflare
- Auto-generated index.ts with getIcon/hasIcon helpers
- Metadata system with searchable keywords per icon

## Build Scripts
- scripts/extract-icons.js: Extract icons from simple-icons
- scripts/generate-icon-index.js: Generate TypeScript index file

* feat(provider): integrate icon system into provider UI components

Add icon customization support to provider management interface:

## Type System Updates

### Provider Interface (src/types.ts)
- Add optional `icon` field for icon name (e.g., "openai", "anthropic")
- Add optional `iconColor` field for hex color (e.g., "#00A67E")

### Form Schema (src/lib/schemas/provider.ts)
- Extend providerSchema with icon and iconColor optional fields
- Maintain backward compatibility with existing providers

## UI Components

### ProviderCard (src/components/providers/ProviderCard.tsx)
- Display ProviderIcon alongside provider name
- Add icon container with hover animation effect
- Adjust layout spacing for icon placement
- Update translate offsets for action buttons

### BasicFormFields (src/components/providers/forms/BasicFormFields.tsx)
- Add icon preview section showing current selection
- Implement fullscreen icon picker dialog
- Auto-apply default color from icon metadata on selection
- Display provider name and icon status in preview

### AddProviderDialog & EditProviderDialog
- Pass icon fields through form submission
- Preserve icon data during provider updates

This enables users to visually distinguish providers in the list
with custom icons, improving UX for multi-provider setups.

* feat(backend): add icon fields to Provider model and default mappings

Extend Rust backend to support provider icon customization:

## Provider Model (src-tauri/src/provider.rs)
- Add `icon: Option<String>` field for icon name
- Add `icon_color: Option<String>` field for hex color
- Use serde rename `iconColor` for frontend compatibility
- Apply skip_serializing_if for clean JSON output
- Update Provider::new() to initialize icon fields as None

## Provider Defaults (src-tauri/src/provider_defaults.rs) [NEW]
- Define ProviderIcon struct with name and color fields
- Create DEFAULT_PROVIDER_ICONS static HashMap with 23 providers:
  - AI providers: OpenAI, Anthropic, Claude, Google, Gemini,
    DeepSeek, Kimi, Moonshot, Zhipu, MiniMax, Baidu, Alibaba,
    Tencent, Meta, Microsoft, Cohere, Perplexity, Mistral, HuggingFace
  - Cloud platforms: AWS, Azure, Huawei, Cloudflare
- Implement infer_provider_icon() with exact and fuzzy matching
- Add unit tests for matching logic (exact, fuzzy, case-insensitive)

## Deep Link Support (src-tauri/src/deeplink.rs)
- Initialize icon fields when creating Provider from deep link import

## Module Registration (src-tauri/src/lib.rs)
- Register provider_defaults module

## Dependencies (Cargo.toml)
- Add once_cell for lazy static initialization

This backend support enables icon persistence and future features
like auto-icon inference during provider creation.

* chore(i18n): add translations for icon picker and provider icon

Add Chinese and English translations for icon customization feature:

## Icon Picker (iconPicker)
- search: "Search Icons" / "搜索图标"
- searchPlaceholder: "Enter icon name..." / "输入图标名称..."
- noResults: "No matching icons found" / "未找到匹配的图标"
- category.aiProvider: "AI Providers" / "AI 服务商"
- category.cloud: "Cloud Platforms" / "云平台"
- category.tool: "Dev Tools" / "开发工具"
- category.other: "Other" / "其他"

## Provider Icon (providerIcon)
- label: "Icon" / "图标"
- colorLabel: "Icon Color" / "图标颜色"
- selectIcon: "Select Icon" / "选择图标"
- preview: "Preview" / "预览"

These translations support the new icon picker UI components
and provider form icon selection interface.

* style(ui): refine header layout and AppSwitcher color scheme

Improve application header and component styling:

## App.tsx Header Layout
- Wrap title and settings button in flex container with gap
- Add vertical divider between title and settings icon
- Apply responsive padding (pl-1 sm:pl-2)
- Reformat JSX for better readability (prettier)
- Fix string template formatting in className

## AppSwitcher Color Update
- Change Claude tab gradient from orange/amber to teal/emerald/green
- Update shadow color to match new teal theme
- Change hover color from orange-500 to teal-500
- Align visual style with emerald/teal brand colors

## Dialog Component Cleanup
- Remove default close button (X icon) from DialogContent
- Allow parent components to control close button placement
- Remove unused lucide-react X import

## index.css Header Border
- Add top border (2px solid) to glass-header
- Apply to both light and dark mode variants
- Improve visual separation of header area

These changes enhance visual consistency and modernize the UI
appearance with a cohesive teal color scheme.

* chore(deps): add icon library and update preset configurations

Add dependencies and utility scripts for icon system:

## Dependencies (package.json)
- Add @lobehub/icons-static-svg@1.73.0
  - High-quality SVG icon library for AI providers
  - Source for extracted icons in src/icons/extracted/
- Update pnpm-lock.yaml accordingly

## Provider Preset Updates (src/config/claudeProviderPresets.ts)
- Add optional `icon` and `iconColor` fields to ProviderPreset interface
- Apply to Anthropic Official preset as example:
  - icon: "anthropic"
  - iconColor: "#D4915D"
- Future presets can include default icon configurations

## Utility Script (scripts/filter-icons.js) [NEW]
- Helper script for filtering and managing icon assets
- Supports icon discovery and validation workflow
- Complements extract-icons.js and generate-icon-index.js

This completes the icon system infrastructure, providing all
necessary tools and dependencies for icon customization.

* refactor(ui): simplify AppSwitcher styles and migrate to local SVG icons

- Replace complex gradient animations with clean, minimal tab design
- Migrate from @lobehub/icons CDN to local SVG assets for better reliability
- Fix clippy warning in error.rs (use inline format args)
- Improve code formatting in skill service and commands
- Reduce CSS complexity in AppSwitcher component (removed blur effects and gradients)
- Update BrandIcons to use imported local SVG files instead of dynamic image loading

This improves performance, reduces external dependencies, and provides a cleaner UI experience.

* style(ui): hide scrollbars across all browsers and optimize form layout

- Hide scrollbars globally with cross-browser support:
  * WebKit browsers (Chrome, Safari, Edge): ::-webkit-scrollbar { display: none }
  * Firefox: scrollbar-width: none
  * IE 10+: -ms-overflow-style: none
- Remove custom scrollbar styling (width, colors, hover states)
- Reorganize BasicFormFields layout:
  * Move icon picker to top center as a clickable preview (80x80)
  * Change name and notes fields to horizontal grid layout (md:grid-cols-2)
  * Remove icon preview section from bottom
  * Improve visual hierarchy and space efficiency

This provides a cleaner, more modern UI with hidden scrollbars while maintaining full scroll functionality.

* refactor(layout): standardize max-width to 60rem and optimize padding structure

- Unify container max-width across components:
  * Replace max-w-4xl with max-w-[60rem] in App.tsx provider list
  * Replace max-w-5xl with max-w-[60rem] in PromptPanel
  * Move padding from header element to inner container for consistent spacing
- Optimize padding hierarchy:
  * Remove px-6 from header element, add to inner header container
  * Remove px-6 from main element, keep it only in provider list container
  * Consolidate PromptPanel padding: move px-6 from nested divs to outer container
  * Remove redundant pl-1 sm:pl-2 from logo/title area
- Benefits:
  * Consistent 60rem max-width provides better readability on wide screens
  * Simplified padding structure reduces CSS complexity
  * Cleaner responsive behavior with unified spacing rules

This creates a more maintainable and visually consistent layout system.

* refactor(ui): unify layout system with 60rem max-width and consistent spacing

- Standardize container max-width across all panels:
  * Replace max-w-4xl and max-w-5xl with unified max-w-[60rem]
  * Apply to SettingsPage, UnifiedMcpPanel, SkillsPage, and FullScreenPanel
  * Ensures consistent reading width and visual balance on wide screens

- Optimize padding hierarchy and structure:
  * Move px-6 from parent elements to content containers
  * FullScreenPanel: Add max-w-[60rem] wrapper to header, content, and footer
  * Add border separators (border-t/border-b) to header and footer sections
  * Consolidate nested padding in MCP, Skills, and Prompts panels
  * Remove redundant padding layers for cleaner CSS

- Simplify component styling:
  * MCP list items: Replace card-based layout with modern group-based design
  * Remove unnecessary wrapper divs and flatten DOM structure
  * Update card hover effects with smooth transitions
  * Simplify icon selection dialog (remove description text in BasicFormFields)

- Benefits:
  * Consistent 60rem max-width provides optimal readability
  * Unified spacing rules reduce maintenance complexity
  * Cleaner component hierarchy improves performance
  * Better responsive behavior across different screen sizes
  * More cohesive visual design language throughout the app

This creates a maintainable, scalable design system foundation.

* feat(deeplink): add Claude model fields support and enhance import dialog

- Add Claude-specific model field support in deeplink import:
  * Support model (ANTHROPIC_MODEL) - general default model
  * Support haikuModel (ANTHROPIC_DEFAULT_HAIKU_MODEL)
  * Support sonnetModel (ANTHROPIC_DEFAULT_SONNET_MODEL)
  * Support opusModel (ANTHROPIC_DEFAULT_OPUS_MODEL)
  * Backend: Update DeepLinkImportRequest struct to include optional model fields
  * Frontend: Add TypeScript type definitions for new model parameters

- Enhance deeplink demo page (deplink.html):
  * Add 5 new Claude configuration examples showcasing different model setups
  * Add parameter documentation with required/optional tags
  * Include basic config (no models), single model, complete 4-model, partial models, and third-party provider examples
  * Improve visual design with param-list component and color-coded badges
  * Add detailed descriptions for each configuration scenario

- Redesign DeepLinkImportDialog layout:
  * Switch from 3-column to compact 2-column grid layout
  * Reduce dialog width from 500px to 650px for better content display
  * Add dedicated section for Claude model configurations with blue highlight box
  * Use uppercase labels and smaller text for more information density
  * Add truncation and tooltips for long URLs
  * Improve visual hierarchy with spacing and grouping
  * Increase z-index to 9999 to ensure dialog appears on top

- Minor UI refinements:
  * Update App.tsx layout adjustments
  * Optimize McpFormModal styling
  * Refine ProviderCard and BasicFormFields components

This enables users to import Claude providers with precise model configurations via deeplink.

* feat(deeplink): add config file support for deeplink import

Support importing provider configuration from embedded or remote config files.
- Add base64 dependency for config content encoding
- Support config, configFormat, and configUrl parameters
- Make homepage/endpoint/apiKey optional when config is provided
- Add config parsing and merging logic

* feat(deeplink): enhance dialog with config file preview

Add config file parsing and preview in deep link import dialog.
- Support Base64 encoded config display
- Add config file source indicator (embedded/remote)
- Parse and display config fields by app type (Claude/Codex/Gemini)
- Mask sensitive values in config preview
- Improve dialog layout and content organization

* refactor(ui): unify dialog styles and improve layout consistency

Standardize dialog and panel components across the application.
- Update dialog background to use semantic color tokens
- Adjust FullScreenPanel max-width to 56rem for better alignment
- Add drag region and prevent body scroll in full-screen panels
- Optimize button sizes and spacing in panel headers
- Apply consistent styling to all dialog-based components

* i18n: add deeplink config preview translations

Add missing translation keys for config file preview feature.
- Add configSource, configEmbedded, configRemote labels
- Add configDetails and configUrl display strings
- Support both Chinese and English versions

* feat(deeplink): enhance test page with v3.8 config file examples

Improve deeplink test page with comprehensive config file import examples.
- Add version badge for v3.8 features
- Add copy-to-clipboard functionality for all deep links
- Add Claude config file import examples (embedded/remote)
- Add Codex config file import examples (auth.json + config.toml)
- Add Gemini config file import examples (.env format)
- Add config generator tool for easy testing
- Update UI with better styling and layout

* feat(settings): add autoSaveSettings for lightweight auto-save

Add optimized auto-save function for General tab settings.
- Add autoSaveSettings method for non-destructive auto-save
- Only trigger system APIs when values actually change
- Avoid unnecessary auto-launch and plugin config updates
- Update tests to cover new functionality

* refactor(settings): simplify settings page layout and auto-save

Reorganize settings page structure and integrate autoSaveSettings.
- Move save button inline within Advanced tab content
- Remove sticky footer for cleaner layout
- Use autoSaveSettings for General tab settings
- Simplify dialog close behavior
- Refactor ImportExportSection layout

* style(providers): optimize card layout and action button sizes

Improve provider card visual density and action buttons.
- Reduce icon button sizes for compact layout
- Adjust drag handle and icon sizes
- Tighten spacing between action buttons
- Update hover translate values for better alignment

* refactor(mcp): improve form modal layout with adaptive height editor

Restructure MCP form modal for better space utilization.
- Split form into upper form fields and lower JSON editor sections
- Add full-height mode support for JsonEditor component
- Use flex layout for editor to fill available space
- Update PromptFormPanel to use full-height editor
- Fix locale text formatting

* style: unify list item styles with semantic colors

Apply consistent styling to list items across components.
- Replace hardcoded colors with semantic tokens in MCP and Prompt list items
- Add glass effect container to EndpointSpeedTest panel
- Format code for better readability

* style: format template literals for better readability

Improve code formatting for conditional className expressions.
- Break long template literals across multiple lines
- Maintain consistent formatting in MCP form and endpoint test components

* feat(deeplink): add config merge command for preview

Expose config merging functionality to frontend for preview.
- Add merge_deeplink_config Tauri command
- Make parse_and_merge_config public for reuse
- Enable frontend to display complete config before import

* feat(deeplink): merge and display config in import dialog

Enhance import dialog to fetch and display complete config.
- Call mergeDeeplinkConfig API when config is present
- Add UTF-8 base64 decoding support for config content
- Add scrollable content area with custom scrollbar styling
- Show complete configuration before user confirms import

* i18n: add config merge error message

Add translation for config file merge error handling.

* style(deeplink): format test page HTML for better readability

Improve HTML formatting in deeplink test page.
- Format multiline attributes for better readability
- Add consistent indentation to nested elements
- Break long lines in buttons and links

* refactor(usage): improve footer layout with two-row design

Reorganize usage footer for better readability and space efficiency.
- Split into two rows: update time + refresh button (row 1), usage stats (row 2)
- Move refresh button to top row next to update time
- Remove card background for cleaner look
- Add fallback text when never updated
- Improve spacing and alignment
- Format template literals for consistency
2025-11-22 19:18:35 +08:00
Bill ZHANG
824bf796a8 fix(gemini): correct MCP server config format for Gemini CLI (#275)
Transform MCP server configurations to match Gemini CLI's expected format:
- Remove 'type' field (Gemini infers transport from field presence)
- Use 'httpUrl' field for HTTP streaming servers (type: "http")
- Keep 'url' field for SSE servers (type: "sse")
- Keep 'command' field for stdio servers unchanged

This fixes MCP servers not being recognized by Gemini CLI when
configured through cc-switch.
2025-11-22 19:18:05 +08:00
YoVinchen
f02efbd2b7 Merge branch 'main' into refactor/ui
# Conflicts:
#	src/components/skills/SkillsPage.tsx
2025-11-22 18:46:32 +08:00
YoVinchen
a39b1d8698 refactor(usage): improve footer layout with two-row design
Reorganize usage footer for better readability and space efficiency.
- Split into two rows: update time + refresh button (row 1), usage stats (row 2)
- Move refresh button to top row next to update time
- Remove card background for cleaner look
- Add fallback text when never updated
- Improve spacing and alignment
- Format template literals for consistency
2025-11-22 18:44:35 +08:00
YoVinchen
86255fe106 style(deeplink): format test page HTML for better readability
Improve HTML formatting in deeplink test page.
- Format multiline attributes for better readability
- Add consistent indentation to nested elements
- Break long lines in buttons and links
2025-11-22 18:09:52 +08:00
YoVinchen
4acd48adc9 i18n: add config merge error message
Add translation for config file merge error handling.
2025-11-22 18:06:25 +08:00
YoVinchen
d4cd8105d1 feat(deeplink): merge and display config in import dialog
Enhance import dialog to fetch and display complete config.
- Call mergeDeeplinkConfig API when config is present
- Add UTF-8 base64 decoding support for config content
- Add scrollable content area with custom scrollbar styling
- Show complete configuration before user confirms import
2025-11-22 18:05:52 +08:00
YoVinchen
2b1ae2aa71 feat(deeplink): add config merge command for preview
Expose config merging functionality to frontend for preview.
- Add merge_deeplink_config Tauri command
- Make parse_and_merge_config public for reuse
- Enable frontend to display complete config before import
2025-11-22 17:58:18 +08:00
YoVinchen
cf57fbed7b style: format template literals for better readability
Improve code formatting for conditional className expressions.
- Break long template literals across multiple lines
- Maintain consistent formatting in MCP form and endpoint test components
2025-11-22 17:22:36 +08:00
Jason
b64bb6cfa1 chore: bump version to v3.7.1
Prepare for v3.7.1 maintenance release.

**Version Updates**:
- package.json: 3.7.0 → 3.7.1
- src-tauri/Cargo.toml: 3.7.0 → 3.7.1
- src-tauri/tauri.conf.json: 3.7.0 → 3.7.1
- README.md: version badge updated
- README_ZH.md: version badge updated

**CHANGELOG.md**:
- Added v3.7.1 release notes (2025-11-22)
- 3 bug fixes (Skills installation, Gemini persistence, dialog overlay)
- 2 new features (Gemini config directory, ArchLinux support)
- 3 improvements (error i18n, download timeout, code formatting)
- 1 reverted feature (auto-launch)

**Code Formatting**:
- Applied prettier to SkillsPage.tsx and skillErrorParser.ts

**Pre-Release Checks**:
 TypeScript type check passed
 Prettier format check passed
 All version numbers synchronized
2025-11-22 16:57:09 +08:00
Jason
d65513ae7d docs: add v3.7.1 release documentation
Add release notes for v3.7.1 maintenance release, with v3.7.1 updates at top and complete v3.7.0 documentation below.

**v3.7.1 Updates**:
- Bug Fixes: Skills installation, Gemini config persistence, dialog overlay protection
- New Features: Gemini config directory support, ArchLinux installation
- Improvements: Skills error i18n enhancement (28+ keys), code formatting

**v3.7.0 Complete Documentation**:
- Six major features (Gemini CLI, MCP v3.7.0, Skills, Prompts, Deep Link, Conflict Detection)
- Technical statistics (152 files, +18,104 / -3,732 lines)
- Strategic positioning shift: Provider Switcher → AI CLI Management Platform

Files:
- docs/release-note-v3.7.1-zh.md (Chinese)
- docs/release-note-v3.7.1-en.md (English)
2025-11-22 16:53:00 +08:00
YoVinchen
eefb764f72 style: unify list item styles with semantic colors
Apply consistent styling to list items across components.
- Replace hardcoded colors with semantic tokens in MCP and Prompt list items
- Add glass effect container to EndpointSpeedTest panel
- Format code for better readability
2025-11-22 16:50:35 +08:00
YoVinchen
4ed3e3bf84 refactor(mcp): improve form modal layout with adaptive height editor
Restructure MCP form modal for better space utilization.
- Split form into upper form fields and lower JSON editor sections
- Add full-height mode support for JsonEditor component
- Use flex layout for editor to fill available space
- Update PromptFormPanel to use full-height editor
- Fix locale text formatting
2025-11-22 16:11:06 +08:00
Jason
29e32f73f3 docs(readme): update for v3.7.0 release with six major features
Update both English and Chinese README files to reflect the major platform positioning shift and new capabilities:

**Platform Positioning**
- Update tagline: "From Provider Switcher to All-in-One AI CLI Management Platform"
- Emphasize unified management of configurations, MCP, Skills, and Prompts

**v3.7.0 Highlights**
- Gemini CLI Integration: Third supported AI CLI with dual-file config
- Claude Skills Management: GitHub integration, lifecycle management
- Prompts Management: Multi-preset system with Markdown editor
- MCP v3.7.0 Unified Architecture: SSE transport, smart parser, cross-app sync
- Deep Link Protocol: ccswitch:// for one-click config sharing
- Environment Conflict Detection: Auto-detect and resolve config conflicts

**Documentation Updates**
- Add v3.7.0 release notes link
- Expand Quick Start with Skills and Prompts management guides
- Update configuration files section with Gemini environment variables
- Add ShanDianShuo sponsor logos and information

**Technical Details**
- 18,000+ lines of new code across 6 core features
- Cross-app support for Claude Code, Codex, and Gemini CLI
- Complete usage instructions for new management systems
2025-11-22 16:05:09 +08:00
YoVinchen
99471f6706 style(providers): optimize card layout and action button sizes
Improve provider card visual density and action buttons.
- Reduce icon button sizes for compact layout
- Adjust drag handle and icon sizes
- Tighten spacing between action buttons
- Update hover translate values for better alignment
2025-11-22 15:37:52 +08:00
YoVinchen
4210b1547c refactor(settings): simplify settings page layout and auto-save
Reorganize settings page structure and integrate autoSaveSettings.
- Move save button inline within Advanced tab content
- Remove sticky footer for cleaner layout
- Use autoSaveSettings for General tab settings
- Simplify dialog close behavior
- Refactor ImportExportSection layout
2025-11-22 15:35:08 +08:00
YoVinchen
cfee4d6fcc feat(settings): add autoSaveSettings for lightweight auto-save
Add optimized auto-save function for General tab settings.
- Add autoSaveSettings method for non-destructive auto-save
- Only trigger system APIs when values actually change
- Avoid unnecessary auto-launch and plugin config updates
- Update tests to cover new functionality
2025-11-22 15:27:32 +08:00
YoVinchen
24dc628130 feat(deeplink): enhance test page with v3.8 config file examples
Improve deeplink test page with comprehensive config file import examples.
- Add version badge for v3.8 features
- Add copy-to-clipboard functionality for all deep links
- Add Claude config file import examples (embedded/remote)
- Add Codex config file import examples (auth.json + config.toml)
- Add Gemini config file import examples (.env format)
- Add config generator tool for easy testing
- Update UI with better styling and layout
2025-11-22 14:07:55 +08:00
YoVinchen
bf74620051 i18n: add deeplink config preview translations
Add missing translation keys for config file preview feature.
- Add configSource, configEmbedded, configRemote labels
- Add configDetails and configUrl display strings
- Support both Chinese and English versions
2025-11-22 14:05:43 +08:00
YoVinchen
e8d4397b3a refactor(ui): unify dialog styles and improve layout consistency
Standardize dialog and panel components across the application.
- Update dialog background to use semantic color tokens
- Adjust FullScreenPanel max-width to 56rem for better alignment
- Add drag region and prevent body scroll in full-screen panels
- Optimize button sizes and spacing in panel headers
- Apply consistent styling to all dialog-based components
2025-11-22 14:03:09 +08:00
YoVinchen
2b0bc73276 feat(deeplink): enhance dialog with config file preview
Add config file parsing and preview in deep link import dialog.
- Support Base64 encoded config display
- Add config file source indicator (embedded/remote)
- Parse and display config fields by app type (Claude/Codex/Gemini)
- Mask sensitive values in config preview
- Improve dialog layout and content organization
2025-11-22 14:02:22 +08:00
YoVinchen
939a2e4f2b feat(deeplink): add config file support for deeplink import
Support importing provider configuration from embedded or remote config files.
- Add base64 dependency for config content encoding
- Support config, configFormat, and configUrl parameters
- Make homepage/endpoint/apiKey optional when config is provided
- Add config parsing and merging logic
2025-11-22 14:00:15 +08:00
YoVinchen
1a89267986 feat(deeplink): add Claude model fields support and enhance import dialog
- Add Claude-specific model field support in deeplink import:
  * Support model (ANTHROPIC_MODEL) - general default model
  * Support haikuModel (ANTHROPIC_DEFAULT_HAIKU_MODEL)
  * Support sonnetModel (ANTHROPIC_DEFAULT_SONNET_MODEL)
  * Support opusModel (ANTHROPIC_DEFAULT_OPUS_MODEL)
  * Backend: Update DeepLinkImportRequest struct to include optional model fields
  * Frontend: Add TypeScript type definitions for new model parameters

- Enhance deeplink demo page (deplink.html):
  * Add 5 new Claude configuration examples showcasing different model setups
  * Add parameter documentation with required/optional tags
  * Include basic config (no models), single model, complete 4-model, partial models, and third-party provider examples
  * Improve visual design with param-list component and color-coded badges
  * Add detailed descriptions for each configuration scenario

- Redesign DeepLinkImportDialog layout:
  * Switch from 3-column to compact 2-column grid layout
  * Reduce dialog width from 500px to 650px for better content display
  * Add dedicated section for Claude model configurations with blue highlight box
  * Use uppercase labels and smaller text for more information density
  * Add truncation and tooltips for long URLs
  * Improve visual hierarchy with spacing and grouping
  * Increase z-index to 9999 to ensure dialog appears on top

- Minor UI refinements:
  * Update App.tsx layout adjustments
  * Optimize McpFormModal styling
  * Refine ProviderCard and BasicFormFields components

This enables users to import Claude providers with precise model configurations via deeplink.
2025-11-22 03:26:28 +08:00
YoVinchen
127fa5bf9d refactor(ui): unify layout system with 60rem max-width and consistent spacing
- Standardize container max-width across all panels:
  * Replace max-w-4xl and max-w-5xl with unified max-w-[60rem]
  * Apply to SettingsPage, UnifiedMcpPanel, SkillsPage, and FullScreenPanel
  * Ensures consistent reading width and visual balance on wide screens

- Optimize padding hierarchy and structure:
  * Move px-6 from parent elements to content containers
  * FullScreenPanel: Add max-w-[60rem] wrapper to header, content, and footer
  * Add border separators (border-t/border-b) to header and footer sections
  * Consolidate nested padding in MCP, Skills, and Prompts panels
  * Remove redundant padding layers for cleaner CSS

- Simplify component styling:
  * MCP list items: Replace card-based layout with modern group-based design
  * Remove unnecessary wrapper divs and flatten DOM structure
  * Update card hover effects with smooth transitions
  * Simplify icon selection dialog (remove description text in BasicFormFields)

- Benefits:
  * Consistent 60rem max-width provides optimal readability
  * Unified spacing rules reduce maintenance complexity
  * Cleaner component hierarchy improves performance
  * Better responsive behavior across different screen sizes
  * More cohesive visual design language throughout the app

This creates a maintainable, scalable design system foundation.
2025-11-22 02:41:17 +08:00
YoVinchen
0d4be40c25 refactor(layout): standardize max-width to 60rem and optimize padding structure
- Unify container max-width across components:
  * Replace max-w-4xl with max-w-[60rem] in App.tsx provider list
  * Replace max-w-5xl with max-w-[60rem] in PromptPanel
  * Move padding from header element to inner container for consistent spacing
- Optimize padding hierarchy:
  * Remove px-6 from header element, add to inner header container
  * Remove px-6 from main element, keep it only in provider list container
  * Consolidate PromptPanel padding: move px-6 from nested divs to outer container
  * Remove redundant pl-1 sm:pl-2 from logo/title area
- Benefits:
  * Consistent 60rem max-width provides better readability on wide screens
  * Simplified padding structure reduces CSS complexity
  * Cleaner responsive behavior with unified spacing rules

This creates a more maintainable and visually consistent layout system.
2025-11-22 02:03:50 +08:00
YoVinchen
00720ecf30 style(ui): hide scrollbars across all browsers and optimize form layout
- Hide scrollbars globally with cross-browser support:
  * WebKit browsers (Chrome, Safari, Edge): ::-webkit-scrollbar { display: none }
  * Firefox: scrollbar-width: none
  * IE 10+: -ms-overflow-style: none
- Remove custom scrollbar styling (width, colors, hover states)
- Reorganize BasicFormFields layout:
  * Move icon picker to top center as a clickable preview (80x80)
  * Change name and notes fields to horizontal grid layout (md:grid-cols-2)
  * Remove icon preview section from bottom
  * Improve visual hierarchy and space efficiency

This provides a cleaner, more modern UI with hidden scrollbars while maintaining full scroll functionality.
2025-11-22 01:47:00 +08:00
YoVinchen
de7f93d513 refactor(ui): simplify AppSwitcher styles and migrate to local SVG icons
- Replace complex gradient animations with clean, minimal tab design
- Migrate from @lobehub/icons CDN to local SVG assets for better reliability
- Fix clippy warning in error.rs (use inline format args)
- Improve code formatting in skill service and commands
- Reduce CSS complexity in AppSwitcher component (removed blur effects and gradients)
- Update BrandIcons to use imported local SVG files instead of dynamic image loading

This improves performance, reduces external dependencies, and provides a cleaner UI experience.
2025-11-22 01:20:21 +08:00
YoVinchen
e7545f8cdf Merge branch 'main' into refactor/ui
# Conflicts:
#	src-tauri/src/services/skill.rs
#	src/components/skills/SkillsPage.tsx
2025-11-22 00:04:28 +08:00
Jason
eb46ac8592 Revert "feat(settings): add auto-launch on system startup feature"
This reverts commit ba336fc416.

Reason: Found issues that need to be addressed before reintroducing
the auto-launch feature. Will reimplement with fixes.
2025-11-21 23:30:56 +08:00
YoVinchen
838a99b5d2 chore(deps): add icon library and update preset configurations
Add dependencies and utility scripts for icon system:

## Dependencies (package.json)
- Add @lobehub/icons-static-svg@1.73.0
  - High-quality SVG icon library for AI providers
  - Source for extracted icons in src/icons/extracted/
- Update pnpm-lock.yaml accordingly

## Provider Preset Updates (src/config/claudeProviderPresets.ts)
- Add optional `icon` and `iconColor` fields to ProviderPreset interface
- Apply to Anthropic Official preset as example:
  - icon: "anthropic"
  - iconColor: "#D4915D"
- Future presets can include default icon configurations

## Utility Script (scripts/filter-icons.js) [NEW]
- Helper script for filtering and managing icon assets
- Supports icon discovery and validation workflow
- Complements extract-icons.js and generate-icon-index.js

This completes the icon system infrastructure, providing all
necessary tools and dependencies for icon customization.
2025-11-21 23:29:48 +08:00
YoVinchen
325c6a5f21 style(ui): refine header layout and AppSwitcher color scheme
Improve application header and component styling:

## App.tsx Header Layout
- Wrap title and settings button in flex container with gap
- Add vertical divider between title and settings icon
- Apply responsive padding (pl-1 sm:pl-2)
- Reformat JSX for better readability (prettier)
- Fix string template formatting in className

## AppSwitcher Color Update
- Change Claude tab gradient from orange/amber to teal/emerald/green
- Update shadow color to match new teal theme
- Change hover color from orange-500 to teal-500
- Align visual style with emerald/teal brand colors

## Dialog Component Cleanup
- Remove default close button (X icon) from DialogContent
- Allow parent components to control close button placement
- Remove unused lucide-react X import

## index.css Header Border
- Add top border (2px solid) to glass-header
- Apply to both light and dark mode variants
- Improve visual separation of header area

These changes enhance visual consistency and modernize the UI
appearance with a cohesive teal color scheme.
2025-11-21 23:28:48 +08:00
YoVinchen
8824462e4c chore(i18n): add translations for icon picker and provider icon
Add Chinese and English translations for icon customization feature:

## Icon Picker (iconPicker)
- search: "Search Icons" / "搜索图标"
- searchPlaceholder: "Enter icon name..." / "输入图标名称..."
- noResults: "No matching icons found" / "未找到匹配的图标"
- category.aiProvider: "AI Providers" / "AI 服务商"
- category.cloud: "Cloud Platforms" / "云平台"
- category.tool: "Dev Tools" / "开发工具"
- category.other: "Other" / "其他"

## Provider Icon (providerIcon)
- label: "Icon" / "图标"
- colorLabel: "Icon Color" / "图标颜色"
- selectIcon: "Select Icon" / "选择图标"
- preview: "Preview" / "预览"

These translations support the new icon picker UI components
and provider form icon selection interface.
2025-11-21 23:25:46 +08:00
Jason
ba336fc416 feat(settings): add auto-launch on system startup feature
Implement auto-launch functionality with proper state synchronization
and error handling across Windows, macOS, and Linux platforms.

Key changes:
- Add auto_launch module using auto-launch crate 0.5
- Define typed errors (AutoLaunchPathError, AutoLaunchEnableError, etc.)
- Sync system state with settings.json on app startup
- Only call system API when auto-launch state actually changes
- Add UI toggle in Window Settings panel
- Add i18n support for auto-launch settings (en/zh)

Implementation details:
- Settings file (settings.json) is the single source of truth
- On startup, system state is synced to match settings.json
- Error handling uses Rust type system with proper error propagation
- Frontend optimized to avoid unnecessary system API calls

Platform support:
- Windows: HKEY_CURRENT_USER registry modification
- macOS: AppleScript-based launch item (configurable to Launch Agent)
- Linux: XDG autostart desktop file
2025-11-21 23:23:35 +08:00
YoVinchen
0c1d94e57b feat(backend): add icon fields to Provider model and default mappings
Extend Rust backend to support provider icon customization:

## Provider Model (src-tauri/src/provider.rs)
- Add `icon: Option<String>` field for icon name
- Add `icon_color: Option<String>` field for hex color
- Use serde rename `iconColor` for frontend compatibility
- Apply skip_serializing_if for clean JSON output
- Update Provider::new() to initialize icon fields as None

## Provider Defaults (src-tauri/src/provider_defaults.rs) [NEW]
- Define ProviderIcon struct with name and color fields
- Create DEFAULT_PROVIDER_ICONS static HashMap with 23 providers:
  - AI providers: OpenAI, Anthropic, Claude, Google, Gemini,
    DeepSeek, Kimi, Moonshot, Zhipu, MiniMax, Baidu, Alibaba,
    Tencent, Meta, Microsoft, Cohere, Perplexity, Mistral, HuggingFace
  - Cloud platforms: AWS, Azure, Huawei, Cloudflare
- Implement infer_provider_icon() with exact and fuzzy matching
- Add unit tests for matching logic (exact, fuzzy, case-insensitive)

## Deep Link Support (src-tauri/src/deeplink.rs)
- Initialize icon fields when creating Provider from deep link import

## Module Registration (src-tauri/src/lib.rs)
- Register provider_defaults module

## Dependencies (Cargo.toml)
- Add once_cell for lazy static initialization

This backend support enables icon persistence and future features
like auto-icon inference during provider creation.
2025-11-21 23:22:51 +08:00
YoVinchen
636a1e2c60 feat(provider): integrate icon system into provider UI components
Add icon customization support to provider management interface:

## Type System Updates

### Provider Interface (src/types.ts)
- Add optional `icon` field for icon name (e.g., "openai", "anthropic")
- Add optional `iconColor` field for hex color (e.g., "#00A67E")

### Form Schema (src/lib/schemas/provider.ts)
- Extend providerSchema with icon and iconColor optional fields
- Maintain backward compatibility with existing providers

## UI Components

### ProviderCard (src/components/providers/ProviderCard.tsx)
- Display ProviderIcon alongside provider name
- Add icon container with hover animation effect
- Adjust layout spacing for icon placement
- Update translate offsets for action buttons

### BasicFormFields (src/components/providers/forms/BasicFormFields.tsx)
- Add icon preview section showing current selection
- Implement fullscreen icon picker dialog
- Auto-apply default color from icon metadata on selection
- Display provider name and icon status in preview

### AddProviderDialog & EditProviderDialog
- Pass icon fields through form submission
- Preserve icon data during provider updates

This enables users to visually distinguish providers in the list
with custom icons, improving UX for multi-provider setups.
2025-11-21 23:21:34 +08:00
YoVinchen
a56a578e91 feat(ui): add icon picker, color picker and provider icon components
Implement comprehensive icon selection system for provider customization:

## New Components

### ProviderIcon (src/components/ProviderIcon.tsx)
- Render SVG icons by name with automatic fallback
- Display provider initials when icon not found
- Support custom sizing via size prop
- Use dangerouslySetInnerHTML for inline SVG rendering

### IconPicker (src/components/IconPicker.tsx)
- Grid-based icon selection with visual preview
- Real-time search filtering by name and keywords
- Integration with icon metadata for display names
- Responsive grid layout (6-10 columns based on screen)

### ColorPicker (src/components/ColorPicker.tsx)
- 12 preset colors for quick selection
- Native color input for custom color picking
- Hex input field for precise color entry
- Visual feedback for selected color state

## Icon Assets (src/icons/extracted/)
- 38 high-quality SVG icons for AI providers and platforms
- Includes: OpenAI, Claude, DeepSeek, Qwen, Kimi, Gemini, etc.
- Cloud platforms: AWS, Azure, Google Cloud, Cloudflare
- Auto-generated index.ts with getIcon/hasIcon helpers
- Metadata system with searchable keywords per icon

## Build Scripts
- scripts/extract-icons.js: Extract icons from simple-icons
- scripts/generate-icon-index.js: Generate TypeScript index file
2025-11-21 23:20:39 +08:00
YoVinchen
c582be265b feat(icon): add icon type system and intelligent inference logic
Introduce a new icon system for provider customization:

- Add IconMetadata and IconPreset interfaces in src/types/icon.ts
  - Define structure for icon name, display name, category, keywords
  - Support default color configuration per icon

- Implement smart icon inference in src/config/iconInference.ts
  - Create iconMappings for 25+ AI providers and cloud platforms
  - Include Claude, DeepSeek, Qwen, Kimi, Google, AWS, Azure, etc.
  - inferIconForPreset(): match provider name to icon config
  - addIconsToPresets(): batch apply icons to preset arrays
  - Support fuzzy matching for flexible name recognition

This foundation enables automatic icon assignment when users add
providers, improving visual identification in the provider list.
2025-11-21 23:19:48 +08:00
Jason
7fa0a7b166 feat(skills): enhance error messages with i18n support
- Add structured error format with error codes and context
- Create skillErrorParser to format errors for user-friendly display
- Add comprehensive i18n keys for all skill-related errors (zh/en)
- Extend download timeout from 15s to 60s to reduce false positives
- Fix: Pass correct error title based on operation context (load/install/uninstall)

Error improvements:
- SKILL_NOT_FOUND: Show skill directory name
- DOWNLOAD_TIMEOUT: Display repo info and timeout duration with network suggestion
- DOWNLOAD_FAILED: Show HTTP status code with specific suggestions (403/404/429)
- SKILL_DIR_NOT_FOUND: Show full path with URL check suggestion
- EMPTY_ARCHIVE: Suggest checking repository URL

Users will now see detailed error messages instead of generic "Error".
2025-11-21 16:20:01 +08:00
YoVinchen
5e54656d45 fix(skills): resolve third-party skills installation failure (#268)
- Add skills_path field to Skill struct
- Use skills_path to construct correct source path during installation
- Fix installation for repos with custom skill subdirectories
2025-11-21 15:02:01 +08:00
YoVinchen
8f218057f3 Merge branch 'fix/third-party-skills-installation' into refactor/ui 2025-11-21 12:35:13 +08:00
YoVinchen
988ea326d9 style(ui): improve window dragging and provider card styles 2025-11-21 11:44:33 +08:00
YoVinchen
f1b0fa2985 test: update test suites to match component refactoring
Comprehensive test updates to align with recent component refactoring
and new auto-launch functionality.

Component Tests:
- AddProviderDialog.test.tsx (10 lines):
  * Updated test cases for new dialog behavior
  * Enhanced mock data for preset selection
  * Improved assertions for validation

- ImportExportSection.test.tsx (16 lines):
  * Updated for new settings page integration
  * Enhanced test coverage for error scenarios
  * Better mock state management

- McpFormModal.test.tsx (60 lines):
  * Extensive updates for form refactoring
  * New test cases for multi-app selection
  * Enhanced validation testing
  * Better coverage of stdio/http server types

- ProviderList.test.tsx (11 lines):
  * Updated for new card layout
  * Enhanced drag-and-drop testing

- SettingsDialog.test.tsx (96 lines):
  * Major updates for SettingsPage migration
  * New test cases for auto-launch functionality
  * Enhanced integration test coverage
  * Better async operation testing

Hook Tests:
- useDirectorySettings.test.tsx (32 lines):
  * Updated for refactored hook logic
  * Enhanced test coverage for edge cases

- useDragSort.test.tsx (36 lines):
  * Simplified test cases
  * Better mock implementation
  * Improved assertions

- useImportExport tests (16 lines total):
  * Updated for new error handling
  * Enhanced test coverage

- useMcpValidation.test.tsx (23 lines):
  * Updated validation test cases
  * Better coverage of error scenarios

- useProviderActions.test.tsx (48 lines):
  * Extensive updates for hook refactoring
  * New test cases for provider operations
  * Enhanced mock data

- useSettings.test.tsx (12 lines):
  * New test cases for auto-launch
  * Enhanced settings state testing
  * Better async operation coverage

Integration Tests:
- App.test.tsx (41 lines):
  * Updated for new routing logic
  * Enhanced navigation testing
  * Better component integration coverage

- SettingsDialog.test.tsx (88 lines):
  * Complete rewrite for SettingsPage
  * New integration test scenarios
  * Enhanced user workflow testing

Mock Infrastructure:
- handlers.ts (117 lines):
  * Major updates for MSW handlers
  * New handlers for auto-launch commands
  * Enhanced error simulation
  * Better request/response mocking

- state.ts (37 lines):
  * Updated mock state structure
  * New state for auto-launch
  * Enhanced state reset functionality

- tauriMocks.ts (10 lines):
  * Updated mock implementations
  * Better type safety

- server.ts & testQueryClient.ts:
  * Minor cleanup (2 lines removed)

Test Infrastructure Improvements:
- Better test isolation
- Enhanced mock data consistency
- Improved async operation testing
- Better error scenario coverage
- Enhanced integration test patterns

Coverage Improvements:
- Net increase of 195 lines of test code
- Better coverage of edge cases
- Enhanced error path testing
- Improved integration test scenarios
- Better mock infrastructure

All tests now pass with the refactored components while maintaining
comprehensive coverage of functionality and edge cases.
2025-11-21 11:12:06 +08:00
YoVinchen
3f470de608 chore(i18n): add auto-launch translation keys
Add translation keys for new auto-launch feature to support
multi-language interface.

Translation Keys Added:
- autoLaunch: Label for auto-launch toggle
- autoLaunchDescription: Explanation of auto-launch functionality
- autoLaunchEnabled: Status message when enabled

Languages Updated:
- Chinese (zh.json): 简体中文翻译
- English (en.json): English translations

The translations maintain consistency with existing terminology
and provide clear, user-friendly descriptions of the auto-launch
feature across both supported languages.
2025-11-21 11:10:16 +08:00
YoVinchen
03af3600b0 style(ui): refine component layouts and improve visual consistency
Comprehensive UI polish across multiple components to enhance visual
design, improve user experience, and maintain consistency.

UsageScriptModal Component (1302 lines refactored):
- Complete layout overhaul for better usability
- Improved script editor with syntax highlighting
- Better template selection interface
- Enhanced test/preview panels with clearer separation
- Improved error feedback and validation messages
- Better modal sizing and responsiveness
- Cleaner tab navigation between sections
- Enhanced code formatting and readability
- Improved loading states for async operations
- Better integration with parent components

MCP Components:
- McpFormModal (42 lines):
  * Streamlined form layout
  * Better server type selection (stdio/http)
  * Improved field grouping and labels
  * Enhanced validation feedback
- UnifiedMcpPanel (14 lines):
  * Minor layout adjustments
  * Better list item spacing
  * Improved server status indicators
  * Enhanced action button placement

Provider Components:
- ProviderCard (11 lines):
  * Refined card layout and spacing
  * Better visual hierarchy
  * Improved badge placement
  * Enhanced hover effects
- ProviderList (5 lines):
  * Minor grid layout adjustments
  * Better drag-and-drop visual feedback
- GeminiConfigSections (4 lines):
  * Field label alignment
  * Improved spacing consistency

Editor & Footer Components:
- JsonEditor (13 lines):
  * Better editor height management
  * Improved error display
  * Enhanced syntax highlighting
- UsageFooter (10 lines):
  * Refined footer layout
  * Better quota display
  * Improved refresh button placement

Settings & Environment:
- ImportExportSection (24 lines):
  * Better button layout
  * Improved action grouping
  * Enhanced visual feedback
- EnvWarningBanner (4 lines):
  * Refined alert styling
  * Better dismiss button placement

Global Styles (index.css):
- Added 11 lines of utility classes
- Improved transition timing
- Better focus indicators
- Enhanced scrollbar styling
- Refined spacing utilities

Design Improvements:
- Consistent spacing using design tokens
- Unified color palette application
- Better typography hierarchy
- Improved shadow system for depth
- Enhanced interactive states (hover, active, focus)
- Better border radius consistency
- Refined animation timings

Accessibility:
- Improved focus indicators
- Better keyboard navigation
- Enhanced screen reader support
- Improved color contrast ratios

Code Quality:
- Net increase of 68 lines due to UsageScriptModal improvements
- Better component organization
- Cleaner style application
- Reduced style duplication

These visual refinements create a more polished and professional
interface while maintaining excellent usability and accessibility
standards across all components.
2025-11-21 11:09:24 +08:00
YoVinchen
482b8a1cab refactor(features): modernize Skills, Prompts and Agents components
Major refactoring of feature components to improve code quality,
user experience, and maintainability.

SkillsPage Component (299 lines refactored):
- Complete rewrite of layout and state management
- Better integration with RepoManagerPanel
- Improved navigation between list and detail views
- Enhanced error handling with user-friendly messages
- Better loading states with skeleton screens
- Optimized re-renders with proper memoization
- Cleaner separation between list and form views
- Improved skill card interactions
- Better responsive design for different screen sizes

RepoManagerPanel Component (370 lines refactored):
- Streamlined repository management workflow
- Enhanced form validation with real-time feedback
- Improved repository list with better visual hierarchy
- Better handling of git operations (clone, pull, delete)
- Enhanced error recovery for network issues
- Cleaner state management reducing complexity
- Improved TypeScript type safety
- Better integration with Skills backend API
- Enhanced loading indicators for async operations

PromptPanel Component (249 lines refactored):
- Modernized layout with FullScreenPanel integration
- Better separation between list and edit modes
- Improved prompt card design with better readability
- Enhanced search and filter functionality
- Cleaner state management for editing workflow
- Better integration with PromptFormPanel
- Improved delete confirmation with safety checks
- Enhanced keyboard navigation support

PromptFormPanel Component (238 lines refactored):
- Streamlined form layout and validation
- Better markdown editor integration
- Real-time preview with syntax highlighting
- Improved validation error display
- Enhanced save/cancel workflow
- Better handling of large prompt content
- Cleaner form state management
- Improved accessibility features

AgentsPanel Component (33 lines modified):
- Minor layout adjustments for consistency
- Better integration with FullScreenPanel
- Improved placeholder states
- Enhanced error boundaries

Type Definitions (types.ts):
- Added 10 new type definitions
- Better type safety for Skills/Prompts/Agents
- Enhanced interfaces for repository management
- Improved typing for form validations

Architecture Improvements:
- Reduced component coupling
- Better prop interfaces with explicit types
- Improved error boundaries
- Enhanced code reusability
- Better testing surface

User Experience Enhancements:
- Smoother transitions between views
- Better visual feedback for actions
- Improved error messages
- Enhanced loading states
- More intuitive navigation flows
- Better responsive layouts

Code Quality:
- Net reduction of 29 lines while adding features
- Improved code organization
- Better naming conventions
- Enhanced documentation
- Cleaner control flow

These changes significantly improve the maintainability and user
experience of core feature components while establishing consistent
patterns for future development.
2025-11-21 11:08:13 +08:00
YoVinchen
ddb0b68b4c refactor(ui): optimize FullScreenPanel, Dialog and App routing
Comprehensive refactoring of core UI components to improve code quality,
maintainability, and user experience.

FullScreenPanel Component:
- Enhanced props interface with better TypeScript types
- Improved layout flexibility with customizable padding
- Better header/footer composition patterns
- Enhanced scroll behavior for long content
- Added support for custom actions in header
- Improved responsive design for different screen sizes
- Better integration with parent components
- Cleaner prop drilling with context where appropriate

Dialog Component (shadcn/ui):
- Updated to latest component patterns
- Improved animation timing and easing
- Better focus trap management
- Enhanced overlay styling with backdrop blur
- Improved accessibility (ARIA labels, keyboard navigation)
- Better close button positioning and styling
- Enhanced mobile responsiveness
- Cleaner composition with DialogHeader/Footer

App Component Routing:
- Refactored routing logic for better clarity
- Improved state management for navigation
- Better integration with settings page
- Enhanced error boundary handling
- Cleaner separation of layout concerns
- Improved provider context propagation
- Better handling of deep links
- Optimized re-renders with React.memo where appropriate

Code Quality Improvements:
- Reduced prop drilling with better component composition
- Improved TypeScript type safety
- Better separation of concerns
- Enhanced code readability with clearer naming
- Eliminated redundant logic

Performance Optimizations:
- Reduced unnecessary re-renders
- Better memoization of callbacks
- Optimized component tree structure
- Improved event handler efficiency

User Experience:
- Smoother transitions and animations
- Better visual feedback for interactions
- Improved loading states
- More consistent behavior across features

These changes create a more maintainable and performant foundation
for the application's UI layer while improving the overall user
experience with smoother interactions and better visual polish.
2025-11-21 11:07:17 +08:00
YoVinchen
524fa94339 refactor(settings): enhance settings page with auto-launch integration
Complete refactoring of settings page architecture to integrate auto-launch
feature and improve overall settings management workflow.

SettingsPage Component:
- Integrate auto-launch toggle with WindowSettings section
- Improve layout and spacing for better visual hierarchy
- Enhanced error handling for settings operations
- Better loading states during settings updates
- Improved accessibility with proper ARIA labels

WindowSettings Component:
- Add auto-launch switch with real-time status
- Integrate with backend auto-launch commands
- Proper error feedback for permission issues
- Visual indicators for current auto-launch state
- Tooltip guidance for auto-launch functionality

useSettings Hook (Major Refactoring):
- Complete rewrite reducing complexity by ~30%
- Better separation of concerns with dedicated handlers
- Improved state management using React Query
- Enhanced auto-launch state synchronization
  * Fetch auto-launch status on mount
  * Real-time updates on toggle
  * Proper error recovery
- Optimized re-renders with better memoization
- Cleaner API for component integration
- Better TypeScript type safety

Settings API:
- Add getAutoLaunch() method
- Add setAutoLaunch(enabled: boolean) method
- Type-safe Tauri command invocations
- Proper error propagation to UI layer

Architecture Improvements:
- Reduced hook complexity from 197 to ~140 effective lines
- Eliminated redundant state management logic
- Better error boundaries and fallback handling
- Improved testability with clearer separation

User Experience Enhancements:
- Instant visual feedback on auto-launch toggle
- Clear error messages for permission issues
- Loading indicators during async operations
- Consistent behavior across all platforms

This refactoring provides a solid foundation for future settings
additions while maintaining code quality and user experience.
2025-11-21 11:06:19 +08:00
YoVinchen
162c92144c feat(backend): add auto-launch functionality
Implement system auto-launch feature to allow CC-Switch to start
automatically on system boot, improving user convenience.

Backend Implementation:
- auto_launch.rs: New module for auto-launch management
  * Cross-platform support using auto-launch crate
  * Enable/disable auto-launch with system integration
  * Proper error handling for permission issues
  * Platform-specific implementations (macOS/Windows/Linux)

Command Layer:
- Add get_auto_launch command to check current status
- Add set_auto_launch command to toggle auto-start
- Integrate commands with settings API

Settings Integration:
- Extend Settings struct with auto_launch field
- Persist auto-launch preference in settings store
- Automatic state synchronization on app startup

Dependencies:
- Add auto-launch ^0.5.0 to Cargo.toml
- Update Cargo.lock with new dependency tree

Technical Details:
- Uses platform-specific auto-launch mechanisms:
  * macOS: Login Items via LaunchServices
  * Windows: Registry Run key
  * Linux: XDG autostart desktop files
- Handles edge cases like permission denials gracefully
- Maintains settings consistency across app restarts

This feature enables users to have CC-Switch readily available
after system boot without manual intervention, particularly useful
for users who frequently switch between API providers.
2025-11-21 11:05:16 +08:00
YoVinchen
b075ee9fbb chore: update dialogs, i18n and improve component integration
Various functional updates and improvements across provider dialogs,
MCP panel, skills page, and internationalization.

Provider Dialogs:
- AddProviderDialog
  * Simplified form state management
  * Improved preset selection workflow
  * Better validation error messages
  * Enhanced template variable handling
- EditProviderDialog
  * Streamlined edit flow with better state synchronization
  * Improved handling of live config backfilling
  * Better error recovery for failed updates
  * Enhanced integration with parent components

MCP & Skills:
- UnifiedMcpPanel
  * Reduced complexity from 140+ to ~95 lines
  * Improved multi-app server management
  * Better server type detection (stdio/http)
  * Enhanced server status indicators
  * Cleaner integration with MCP form modal
- SkillsPage
  * Simplified navigation and state management
  * Better integration with RepoManagerPanel
  * Improved error handling for repository operations
  * Enhanced loading states
- SkillCard
  * Minor layout adjustments
  * Better action button placement

Environment & Configuration:
- EnvWarningBanner
  * Improved conflict detection messages
  * Better visual hierarchy for warnings
  * Enhanced dismissal behavior
- tauri.conf.json
  * Updated build configuration
  * Added new window management options

Internationalization:
- en.json & zh.json
  * Added 17 new translation keys for new features
  * Updated existing keys for better clarity
  * Added translations for new settings page
  * Improved consistency across UI text

Code Cleanup:
- mutations.ts
  * Removed 14 lines of unused mutation definitions
  * Cleaned up deprecated query invalidation logic
  * Better type safety for mutation parameters

Overall Impact:
- Reduced total lines by 51 (-10% in affected files)
- Improved component integration and data flow
- Better error handling and user feedback
- Enhanced i18n coverage for new features

These changes improve the overall polish and integration of various
components while removing technical debt and unused code.
2025-11-21 09:32:39 +08:00
YoVinchen
17cf701bad style(ui): modernize component layouts and visual design
Update UI components with improved layouts, visual hierarchy, and
modern design patterns for better user experience.

Navigation & Brand Components:
- AppSwitcher
  * Enhanced visual design with better spacing
  * Improved active state indicators
  * Smoother transitions and hover effects
  * Better mobile responsiveness
- BrandIcons
  * Optimized icon rendering performance
  * Added support for more provider icons
  * Improved SVG handling and fallbacks
  * Better scaling across different screen sizes

Editor Components:
- JsonEditor
  * Enhanced syntax highlighting
  * Better error visualization
  * Improved code formatting options
  * Added line numbers and code folding support
- UsageScriptModal
  * Complete layout overhaul (1239 lines refactored)
  * Better script editor integration
  * Improved template selection UI
  * Enhanced preview and testing panels
  * Better error feedback and validation

Provider Components:
- ProviderCard
  * Redesigned card layout with modern aesthetics
  * Better information density and readability
  * Improved action buttons placement
  * Enhanced status indicators (active/inactive)
- ProviderList
  * Better grid/list view layouts
  * Improved drag-and-drop visual feedback
  * Enhanced sorting indicators
- ProviderActions
  * Streamlined action menu
  * Better icon consistency
  * Improved tooltips and accessibility

Usage & Footer:
- UsageFooter
  * Redesigned footer layout
  * Better quota visualization
  * Improved refresh controls
  * Enhanced error states

Design System Updates:
- dialog.tsx (shadcn/ui component)
  * Updated to latest design tokens
  * Better overlay animations
  * Improved focus management
- index.css
  * Added 65 lines of global utility classes
  * New animation keyframes
  * Enhanced color variables for dark mode
  * Improved typography scale
- tailwind.config.js
  * Extended theme with new design tokens
  * Added custom animations and transitions
  * New spacing and sizing utilities
  * Enhanced color palette

Visual Improvements:
- Consistent border radius across components
- Unified shadow system for depth perception
- Better color contrast for accessibility (WCAG AA)
- Smoother animations and transitions
- Improved dark mode support

These changes create a more polished, modern interface while
maintaining consistency with the application's design language.
2025-11-21 09:31:36 +08:00
YoVinchen
977185e2d5 refactor(forms): simplify and modernize form components
Comprehensive refactoring of form components to reduce complexity,
improve maintainability, and enhance user experience.

Provider Forms:
- CodexCommonConfigModal & CodexConfigSections
  * Simplified state management with reduced boilerplate
  * Improved field validation and error handling
  * Better layout with consistent spacing
  * Enhanced model selection with visual indicators
- GeminiCommonConfigModal & GeminiConfigSections
  * Streamlined authentication flow (OAuth vs API Key)
  * Cleaner form layout with better grouping
  * Improved validation feedback
  * Better integration with parent components
- CommonConfigEditor
  * Reduced from 178 to 68 lines (-62% complexity)
  * Extracted reusable form patterns
  * Improved JSON editing with syntax validation
  * Better error messages and recovery options
- EndpointSpeedTest
  * Complete rewrite for better UX
  * Real-time testing progress indicators
  * Enhanced error handling with retry logic
  * Visual feedback for test results (color-coded latency)

MCP & Prompts:
- McpFormModal
  * Simplified from 581 to ~360 lines
  * Better stdio/http server type handling
  * Improved form validation
  * Enhanced multi-app selection (Claude/Codex/Gemini)
- PromptPanel
  * Cleaner integration with PromptFormPanel
  * Improved list/grid view switching
  * Better state management for editing workflows
  * Enhanced delete confirmation with safety checks

Code Quality Improvements:
- Reduced total lines by ~251 lines (-24% code reduction)
- Eliminated duplicate validation logic
- Improved TypeScript type safety
- Better component composition and separation of concerns
- Enhanced accessibility with proper ARIA labels

These changes make forms more intuitive, responsive, and easier to
maintain while reducing bundle size and improving runtime performance.
2025-11-21 09:30:30 +08:00
YoVinchen
764ba81ea6 refactor(settings): migrate from dialog to full-screen page layout
Complete migration of settings from modal dialog to dedicated full-screen
page, improving UX and providing more space for configuration options.

Changes:
- Remove SettingsDialog component (legacy modal-based interface)
- Add SettingsPage component with full-screen layout using FullScreenPanel
- Refactor App.tsx routing to support dedicated settings page
  * Add settings route handler
  * Update navigation logic from dialog-based to page-based
  * Integrate with existing app switcher and provider management
- Update ImportExportSection to work with new page layout
  * Improve spacing and layout for better readability
  * Enhanced error handling and user feedback
  * Better integration with page-level actions
- Enhance useSettings hook to support page-based workflow
  * Add navigation state management
  * Improve settings persistence logic
  * Better error boundary handling

Benefits:
- More intuitive navigation with dedicated settings page
- Better use of screen space for complex configurations
- Improved accessibility with clearer visual hierarchy
- Consistent with modern desktop application patterns
- Easier to extend with new settings sections

This change is part of the larger UI refactoring initiative to modernize
the application interface and improve user experience.
2025-11-21 09:28:11 +08:00
YoVinchen
d802b7bf61 feat(components): add reusable full-screen panel components
Add new full-screen panel components to support the UI refactoring:

- FullScreenPanel: Reusable full-screen layout component with header,
  content area, and optional footer. Provides consistent layout for
  settings, prompts, and other full-screen views.

- PromptFormPanel: Dedicated panel for creating and editing prompts
  with markdown preview support. Features real-time validation and
  integrated save/cancel actions.

- AgentsPanel: Panel component for managing agent configurations.
  Provides a consistent interface for agent CRUD operations.

- RepoManagerPanel: Full-featured repository manager panel for Skills.
  Supports repository listing, addition, deletion, and configuration
  management with integrated validation.

These components establish the foundation for the upcoming settings
page migration from dialog-based to full-screen layout.
2025-11-21 09:27:20 +08:00
161 changed files with 10138 additions and 3946 deletions

View File

@@ -5,6 +5,31 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.7.1] - 2025-11-22
### Fixed
- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)
- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers
- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)
### Added
- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings
- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`
### Improved
- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions
- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives
- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards
### Reverted
- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization
---
## [3.7.0] - 2025-11-19
### Major Features

View File

@@ -2,7 +2,7 @@
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
[![Version](https://img.shields.io/badge/version-3.7.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.7.1-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/)
@@ -12,7 +12,9 @@
English | [中文](README_ZH.md) | [Changelog](CHANGELOG.md)
A desktop application for managing and switching between different provider configurations & MCP for Claude Code and Codex.
**From Provider Switcher to All-in-One AI CLI Management Platform**
Unified management for Claude Code, Codex & Gemini CLI provider configurations, MCP servers, Skills extensions, and system prompts.
</div>
@@ -33,6 +35,12 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-en.png" alt="ShanDianShuo" width="150"></td>
<td>Thanks to ShanDianShuo for sponsoring this project! ShanDianShuo is a local-first AI voice input: Millisecond latency, data stays on device, 4x faster than typing, AI-powered correction, Privacy-first, completely free. Doubles your coding efficiency with Claude Code! <a href="https://www.shandianshuo.cn">Free download</a> for Mac/Win</td>
</tr>
</table>
## Screenshots
@@ -43,12 +51,49 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
## Features
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md)
### Current Version: v3.7.0 | [Full Changelog](CHANGELOG.md) | [📋 Release Notes](docs/release-note-v3.7.0-en.md)
**v3.7.0 Major Update (2025-11-19)**
**Six Core Features, 18,000+ Lines of New Code**
- **Gemini CLI Integration**
- Third supported AI CLI (Claude Code / Codex / Gemini)
- Dual-file configuration support (`.env` + `settings.json`)
- Complete MCP server management
- Presets: Google Official (OAuth) / PackyCode / Custom
- **Claude Skills Management System**
- Auto-scan skills from GitHub repositories (3 pre-configured curated repos)
- One-click install/uninstall to `~/.claude/skills/`
- Custom repository support + subdirectory scanning
- Complete lifecycle management (discover/install/update)
- **Prompts Management System**
- Multi-preset system prompt management (unlimited presets, quick switching)
- Cross-app support (Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`)
- Markdown editor (CodeMirror 6 + real-time preview)
- Smart backfill protection, preserves manual modifications
- **MCP v3.7.0 Unified Architecture**
- Single panel manages MCP servers across three applications
- New SSE (Server-Sent Events) transport type
- Smart JSON parser + Codex TOML format auto-correction
- Unified import/export + bidirectional sync
- **Deep Link Protocol**
- `ccswitch://` protocol registration (all platforms)
- One-click import provider configs via shared links
- Security validation + lifecycle integration
- **Environment Variable Conflict Detection**
- Auto-detect cross-app configuration conflicts (Claude/Codex/Gemini/MCP)
- Visual conflict indicators + resolution suggestions
- Override warnings + backup before changes
**Core Capabilities**
- **Provider Management**: One-click switching between Claude Code, Codex, and Gemini API configurations
- **MCP Integration**: Centralized MCP server management with stdio/http support and real-time sync
- **Speed Testing**: Measure API endpoint latency with visual quality indicators
- **Import/Export**: Backup and restore configs with auto-rotation (keep 10 most recent)
- **i18n Support**: Complete Chinese/English localization (UI, errors, tray)
@@ -61,7 +106,6 @@ Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJ
- Granular model configuration (4-tier: Haiku/Sonnet/Opus/Custom)
- WSL environment support with auto-sync on directory change
- 100% hooks test coverage & complete architecture refactoring
- New presets: DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax
**System Features**
@@ -129,9 +173,36 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
### MCP Management
- **Location**: Click "MCP" button in top-right corner
- **Add Server**: Use built-in templates (mcp-fetch, mcp-filesystem) or custom config
- **Add Server**:
- Use built-in templates (mcp-fetch, mcp-filesystem, etc.)
- Support stdio / http / sse transport types
- Configure independent MCP servers for different apps
- **Enable/Disable**: Toggle switches to control which servers sync to live config
- **Sync**: Enabled servers auto-sync to `~/.claude.json` (Claude) or `~/.codex/config.toml` (Codex)
- **Sync**: Enabled servers auto-sync to each app's live files
- **Import/Export**: Import existing MCP servers from Claude/Codex/Gemini config files
### Skills Management (v3.7.0 New)
- **Location**: Click "Skills" button in top-right corner
- **Discover Skills**:
- Auto-scan pre-configured GitHub repositories (Anthropic official, ComposioHQ, community, etc.)
- Add custom repositories (supports subdirectory scanning)
- **Install Skills**: Click "Install" to one-click install to `~/.claude/skills/`
- **Uninstall Skills**: Click "Uninstall" to safely remove and clean up state
- **Manage Repositories**: Add/remove custom GitHub repositories
### Prompts Management (v3.7.0 New)
- **Location**: Click "Prompts" button in top-right corner
- **Create Presets**:
- Create unlimited system prompt presets
- Use Markdown editor to write prompts (syntax highlighting + real-time preview)
- **Switch Presets**: Select preset → Click "Activate" to apply immediately
- **Sync Mechanism**:
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **Protection Mechanism**: Auto-save current prompt content before switching, preserves manual modifications
### Configuration Files
@@ -149,13 +220,15 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
**Gemini**
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth type for quick switching)
- API key field: `GEMINI_API_KEY` inside `.env`
- Tray quick switch: each provider switch rewrites `~/.gemini/.env` so the Gemini CLI picks up the new credentials immediately
- Live config: `~/.gemini/.env` (API key) + `~/.gemini/settings.json` (auth mode)
- API key field: `GEMINI_API_KEY` or `GOOGLE_GEMINI_API_KEY` in `.env`
- Environment variables: Support `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- MCP servers: `~/.gemini/settings.json``mcpServers`
- Tray quick switch: Each provider switch rewrites `~/.gemini/.env`, no need to restart Gemini CLI
**CC Switch Storage**
- Main config (SSOT): `~/.cc-switch/config.json`
- Main config (SSOT): `~/.cc-switch/config.json` (includes providers, MCP, Prompts presets, etc.)
- Settings: `~/.cc-switch/settings.json`
- Backups: `~/.cc-switch/backups/` (auto-rotate, keep 10)

View File

@@ -2,7 +2,7 @@
# Claude Code / Codex / Gemini CLI 全方位辅助工具
[![Version](https://img.shields.io/badge/version-3.7.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/badge/version-3.7.1-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/)
@@ -10,9 +10,11 @@
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[English](README.md) | 中文 | [更新日志](CHANGELOG.md)
[English](README.md) | 中文 | [更新日志](CHANGELOG.md) | [📋 v3.7.0 发布说明](docs/release-note-v3.7.0-zh.md)
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置、MCP的桌面应用。
**从供应商切换器到 AI CLI 一体化管理平台**
统一管理 Claude Code、Codex 与 Gemini CLI 的供应商配置、MCP 服务器、Skills 扩展和系统提示词。
</div>
@@ -33,6 +35,12 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
<td width="180"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></td>
<td>感谢 PackyCode 赞助了本项目PackyCode 是一家稳定、高效的API中转服务商提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码可以享受9折优惠。</td>
</tr>
<tr>
<td width="180"><img src="assets/partners/logos/sds-zh.png" alt="ShanDianShuo" width="150"></td>
<td>感谢闪电说赞助了本项目!闪电说是本地优先的 AI 语音输入法:毫秒级响应,数据不离设备;打字速度提升 4 倍AI 智能纠错;绝对隐私安全,完全免费,配合 Claude Code 写代码效率翻倍!支持 Mac/Win 双平台,<a href="https://www.shandianshuo.cn">免费下载</a></td>
</tr>
</table>
## 界面预览
@@ -45,10 +53,47 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
### 当前版本v3.7.0 | [完整更新日志](CHANGELOG.md)
**v3.7.0 重大更新2025-11-19**
**六大核心功能18,000+ 行新增代码**
- **Gemini CLI 集成**
- 第三个支持的 AI CLIClaude Code / Codex / Gemini
- 双文件配置支持(`.env` + `settings.json`
- 完整 MCP 服务器管理
- 预设Google Official (OAuth) / PackyCode / 自定义
- **Claude Skills 管理系统**
- 从 GitHub 仓库自动扫描技能(预配置 3 个精选仓库)
- 一键安装/卸载到 `~/.claude/skills/`
- 自定义仓库支持 + 子目录扫描
- 完整生命周期管理(发现/安装/更新)
- **Prompts 管理系统**
- 多预设系统提示词管理(无限数量,快速切换)
- 跨应用支持Claude: `CLAUDE.md` / Codex: `AGENTS.md` / Gemini: `GEMINI.md`
- Markdown 编辑器CodeMirror 6 + 实时预览)
- 智能回填保护,保留手动修改
- **MCP v3.7.0 统一架构**
- 单一面板管理三个应用的 MCP 服务器
- 新增 SSE (Server-Sent Events) 传输类型
- 智能 JSON 解析器 + Codex TOML 格式自动修正
- 统一导入/导出 + 双向同步
- **深度链接协议**
- `ccswitch://` 协议注册(全平台)
- 通过共享链接一键导入供应商配置
- 安全验证 + 生命周期集成
- **环境变量冲突检测**
- 自动检测跨应用配置冲突Claude/Codex/Gemini/MCP
- 可视化冲突指示器 + 解决建议
- 覆盖警告 + 更改前备份
**核心功能**
- **供应商管理**:一键切换 Claude Code、Codex 与 Gemini 的 API 配置
- **MCP 集成**:集中管理 MCP 服务器,支持 stdio/http 类型和实时同步
- **速度测试**:测量 API 端点延迟,可视化连接质量指示器
- **导入导出**:备份和恢复配置,自动轮换(保留最近 10 个)
- **国际化支持**完整的中英文本地化UI、错误、托盘
@@ -61,7 +106,6 @@ CC Switch 已经预设了智谱GLM只需要填写 key 即可一键导入编
- 细粒度模型配置四层Haiku/Sonnet/Opus/自定义)
- WSL 环境支持,配置目录切换自动同步
- 100% hooks 测试覆盖 & 完整架构重构
- 新增预设DMXAPI、Azure Codex、AnyRouter、AiHubMix、MiniMax
**系统功能**
@@ -129,9 +173,36 @@ paru -S cc-switch-bin
### MCP 管理
- **位置**:点击右上角"MCP"按钮
- **添加服务器**使用内置模板mcp-fetch、mcp-filesystem或自定义配置
- **添加服务器**
- 使用内置模板mcp-fetch、mcp-filesystem 等)
- 支持 stdio / http / sse 三种传输类型
- 为不同应用配置独立的 MCP 服务器
- **启用/禁用**:切换开关以控制哪些服务器同步到 live 配置
- **同步**:启用的服务器自动同步到 `~/.claude.json`Claude`~/.codex/config.toml`Codex
- **同步**:启用的服务器自动同步到各应用的 live 文件
- **导入/导出**:支持从 Claude/Codex/Gemini 配置文件导入现有 MCP 服务器
### Skills 管理v3.7.0 新增)
- **位置**:点击右上角"Skills"按钮
- **发现技能**
- 自动扫描预配置的 GitHub 仓库Anthropic 官方、ComposioHQ、社区等
- 添加自定义仓库(支持子目录扫描)
- **安装技能**:点击"安装"一键安装到 `~/.claude/skills/`
- **卸载技能**:点击"卸载"安全移除并清理状态
- **管理仓库**:添加/删除自定义 GitHub 仓库
### Prompts 管理v3.7.0 新增)
- **位置**:点击右上角"Prompts"按钮
- **创建预设**
- 创建无限数量的系统提示词预设
- 使用 Markdown 编辑器编写提示词(语法高亮 + 实时预览)
- **切换预设**:选择预设 → 点击"激活"立即应用
- **同步机制**
- Claude: `~/.claude/CLAUDE.md`
- Codex: `~/.codex/AGENTS.md`
- Gemini: `~/.gemini/GEMINI.md`
- **保护机制**:切换前自动保存当前提示词内容,保留手动修改
### 配置文件
@@ -149,13 +220,15 @@ paru -S cc-switch-bin
**Gemini**
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/settings.json`(保存认证模式,支持托盘快速切换
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`Gemini CLI 无需额外操作即可使用新配置
- Live 配置:`~/.gemini/.env`API Key+ `~/.gemini/settings.json`(保存认证模式)
- API key 字段:`.env` 文件中的 `GEMINI_API_KEY``GOOGLE_GEMINI_API_KEY`
- 环境变量:支持 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等自定义变量
- MCP 服务器:`~/.gemini/settings.json``mcpServers`
- 托盘快速切换:每次切换供应商都会重写 `~/.gemini/.env`,无需重启 Gemini CLI 即可生效
**CC Switch 存储**
- 主配置SSOT`~/.cc-switch/config.json`
- 主配置SSOT`~/.cc-switch/config.json`包含供应商、MCP、Prompts 预设等)
- 设置:`~/.cc-switch/settings.json`
- 备份:`~/.cc-switch/backups/`(自动轮换,保留 10 个)

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,481 @@
# CC Switch v3.7.1
> Stability Enhancements and User Experience Improvements
**[中文更新说明 Chinese Documentation →](release-note-v3.7.1-zh.md)**
---
## v3.7.1 Updates
**Release Date**: 2025-11-22
**Code Changes**: 17 files, +524 / -81 lines
### Bug Fixes
- **Fix Third-Party Skills Installation Failure** (#268)
Fixed installation issues for skills repositories with custom subdirectories, now supports repos like `ComposioHQ/awesome-claude-skills` with subdirectories
- **Fix Gemini Configuration Persistence Issue**
Resolved the issue where settings.json edits in Gemini form were lost when switching providers
- **Prevent Dialogs from Closing on Overlay Click**
Added protection against clicking overlay/backdrop, preventing accidental form data loss across all 11 dialog components
### New Features
- **Gemini Configuration Directory Support** (#255)
Added Gemini configuration directory option in settings, supports customizing `~/.gemini/` path
- **ArchLinux Installation Support** (#259)
Added AUR installation method: `paru -S cc-switch-bin`
### Improvements
- **Skills Error Message i18n Enhancement**
Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions, extended download timeout from 15s to 60s
- **Code Formatting**
Applied unified Rust and TypeScript code formatting standards
### Download
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the latest version
---
## v3.7.0 Complete Release Notes
> From Provider Switcher to All-in-One AI CLI Management Platform
**Release Date**: 2025-11-19
**Commits**: 85 from v3.6.0
**Code Changes**: 152 files, +18,104 / -3,732 lines
---
## New Features
### Gemini CLI Integration
Complete support for Google Gemini CLI, becoming the third supported application (Claude Code, Codex, Gemini).
**Core Capabilities**:
- **Dual-file configuration** - Support for both `.env` and `settings.json` formats
- **Auto-detection** - Automatically detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.
- **Full MCP support** - Complete MCP server management for Gemini
- **Deep link integration** - Import via `ccswitch://` protocol
- **System tray** - Quick-switch from tray menu
**Provider Presets**:
- **Google Official** - OAuth authentication support
- **PackyCode** - Partner integration
- **Custom** - Full customization support
**Technical Implementation**:
- New backend modules: `gemini_config.rs` (20KB), `gemini_mcp.rs`
- Form synchronization with environment editor
- Dual-file atomic writes
---
### MCP v3.7.0 Unified Architecture
Complete refactoring of MCP management system for cross-application unification.
**Architecture Improvements**:
- **Unified panel** - Single interface for Claude/Codex/Gemini MCP servers
- **SSE transport** - New Server-Sent Events support
- **Smart parser** - Fault-tolerant JSON parsing
- **Format correction** - Auto-fix Codex `[mcp_servers]` format
- **Extended fields** - Preserve custom TOML fields
**User Experience**:
- Default app selection in forms
- JSON formatter for validation
- Improved visual hierarchy
- Better error messages
**Import/Export**:
- Unified import from all three apps
- Bidirectional synchronization
- State preservation
---
### Claude Skills Management System
**Approximately 2,000 lines of code** - A complete skill ecosystem platform.
**GitHub Integration**:
- Auto-scan skills from GitHub repositories
- Pre-configured repos:
- `ComposioHQ/awesome-claude-skills` - Curated collection
- `anthropics/skills` - Official Anthropic skills
- `cexll/myclaude` - Community contributions
- Add custom repositories
- Subdirectory scanning support (`skillsPath`)
**Lifecycle Management**:
- **Discover** - Auto-detect `SKILL.md` files
- **Install** - One-click to `~/.claude/skills/`
- **Uninstall** - Safe removal with tracking
- **Update** - Check for updates (infrastructure ready)
**Technical Architecture**:
- **Backend**: `SkillService` (526 lines) with GitHub API integration
- **Frontend**: SkillsPage, SkillCard, RepoManager
- **UI Components**: Badge, Card, Table (shadcn/ui)
- **State**: Persistent storage in `config.json`
- **i18n**: 47+ translation keys
---
### Prompts Management System
**Approximately 1,300 lines of code** - Complete system prompt management.
**Multi-Preset Management**:
- Create unlimited prompt presets
- Quick switch between presets
- One active prompt at a time
- Delete protection for active prompts
**Cross-App Support**:
- **Claude**: `~/.claude/CLAUDE.md`
- **Codex**: `~/.codex/AGENTS.md`
- **Gemini**: `~/.gemini/GEMINI.md`
**Markdown Editor**:
- Full-featured CodeMirror 6 integration
- Syntax highlighting
- Dark theme (One Dark)
- Real-time preview
**Smart Synchronization**:
- **Auto-write** - Immediately write to live files
- **Backfill protection** - Save current content before switching
- **Auto-import** - Import from live files on first launch
- **Modification protection** - Preserve manual modifications
**Technical Implementation**:
- **Backend**: `PromptService` (213 lines)
- **Frontend**: PromptPanel (177), PromptFormModal (160), MarkdownEditor (159)
- **Hooks**: usePromptActions (152 lines)
- **i18n**: 41+ translation keys
---
### Deep Link Protocol (ccswitch://)
One-click provider configuration import via URL scheme.
**Features**:
- Protocol registration on all platforms
- Import from shared links
- Lifecycle integration
- Security validation
---
### Environment Variable Conflict Detection
Intelligent detection and management of configuration conflicts.
**Detection Scope**:
- **Claude & Codex** - Cross-app conflicts
- **Gemini** - Auto-discovery
- **MCP** - Server configuration conflicts
**Management Features**:
- Visual conflict indicators
- Resolution suggestions
- Override warnings
- Backup before changes
---
## Improvements
### Provider Management
**New Presets**:
- **DouBaoSeed** - ByteDance's DouBao
- **Kimi For Coding** - Moonshot AI
- **BaiLing** - BaiLing AI
- **Removed AnyRouter** - To avoid confusion
**Enhancements**:
- Model name configuration for Codex and Gemini
- Provider notes field for organization
- Enhanced preset metadata
### Configuration Management
- **Common config migration** - From localStorage to `config.json`
- **Unified persistence** - Shared across all apps
- **Auto-import** - First launch configuration import
- **Backfill priority** - Correct handling of live files
### UI/UX Improvements
**Design System**:
- **macOS native** - System-aligned color scheme
- **Window centering** - Default centered position
- **Visual polish** - Improved spacing and hierarchy
**Interactions**:
- **Password input** - Fixed Edge/IE reveal buttons
- **URL overflow** - Fixed card overflow
- **Error copying** - Copy-to-clipboard errors
- **Tray sync** - Real-time drag-and-drop sync
---
## Bug Fixes
### Critical Fixes
- **Usage script validation** - Boundary checks
- **Gemini validation** - Relaxed constraints
- **TOML parsing** - CJK quote handling
- **MCP fields** - Custom field preservation
- **White screen** - FormLabel crash fix
### Stability
- **Tray safety** - Pattern matching instead of unwrap
- **Error isolation** - Tray failures don't block operations
- **Import classification** - Correct category assignment
### UI Fixes
- **Model placeholders** - Removed misleading hints
- **Base URL** - Auto-fill for third-party providers
- **Drag sort** - Tray menu synchronization
---
## Technical Improvements
### Architecture
**MCP v3.7.0**:
- Removed legacy code (~1,000 lines)
- Unified initialization structure
- Backward compatibility maintained
- Comprehensive code formatting
**Platform Compatibility**:
- Windows winreg API fix (v0.52)
- Safe pattern matching (no `unwrap()`)
- Cross-platform tray handling
### Configuration
**Synchronization**:
- MCP sync across all apps
- Gemini form-editor sync
- Dual-file reading (.env + settings.json)
**Validation**:
- Input boundary checks
- TOML quote normalization (CJK)
- Custom field preservation
- Enhanced error messages
### Code Quality
**Type Safety**:
- Complete TypeScript coverage
- Rust type refinements
- API contract validation
**Testing**:
- Simplified assertions
- Better test coverage
- Integration test updates
**Dependencies**:
- Tauri 2.8.x
- Rust: `anyhow`, `zip`, `serde_yaml`, `tempfile`
- Frontend: CodeMirror 6 packages
- winreg 0.52 (Windows)
---
## Technical Statistics
```
Total Changes:
- Commits: 85
- Files: 152 changed
- Additions: +18,104 lines
- Deletions: -3,732 lines
New Modules:
- Skills Management: 2,034 lines (21 files)
- Prompts Management: 1,302 lines (20 files)
- Gemini Integration: ~1,000 lines
- MCP Refactor: ~3,000 lines refactored
Code Distribution:
- Backend (Rust): ~4,500 lines new
- Frontend (React): ~3,000 lines new
- Configuration: ~1,500 lines refactored
- Tests: ~500 lines
```
---
## Strategic Positioning
### From Tool to Platform
v3.7.0 represents a shift in CC Switch's positioning:
| Aspect | v3.6 | v3.7.0 |
| ----------------- | ------------------------ | ---------------------------- |
| **Identity** | Provider Switcher | AI CLI Management Platform |
| **Scope** | Configuration Management | Ecosystem Management |
| **Applications** | Claude + Codex | Claude + Codex + Gemini |
| **Capabilities** | Switch configs | Extend capabilities (Skills) |
| **Customization** | Manual editing | Visual management (Prompts) |
| **Integration** | Isolated apps | Unified management (MCP) |
### Six Pillars of AI CLI Management
1. **Configuration Management** - Provider switching and management
2. **Capability Extension** - Skills installation and lifecycle
3. **Behavior Customization** - System prompt presets
4. **Ecosystem Integration** - Deep links and sharing
5. **Multi-AI Support** - Claude/Codex/Gemini
6. **Intelligent Detection** - Conflict prevention
---
## Download & Installation
### System Requirements
- **Windows**: Windows 10+
- **macOS**: macOS 10.15 (Catalina)+
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
### Download Links
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download:
- **Windows**: `CC-Switch-Windows.msi` or `-Portable.zip`
- **macOS**: `CC-Switch-macOS.tar.gz` or `.zip`
- **Linux**: `CC-Switch-Linux.AppImage` or `.deb`
- **ArchLinux**: `paru -S cc-switch-bin`
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
---
## Migration Notes
### From v3.6.x
**Automatic migration** - No action required, configs are fully compatible
### From v3.1.x or Earlier
**Two-step migration required**:
1. First upgrade to v3.2.x (performs one-time migration)
2. Then upgrade to v3.7.0
### New Features
- **Skills**: No migration needed, start fresh
- **Prompts**: Auto-import from live files on first launch
- **Gemini**: Install Gemini CLI separately if needed
- **MCP v3.7.0**: Backward compatible with previous configs
---
## Acknowledgments
### Contributors
Thanks to all contributors who made this release possible:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini integration implementation
- [@farion1231](https://github.com/farion1231) - From developer to issue responder
- Community members for testing and feedback
### Sponsors
**Z.ai** - GLM CODING PLAN sponsor
[Get 10% OFF with this link](https://z.ai/subscribe?ic=8JVLJQFSKB)
**PackyCode** - API relay service partner
[Register with "cc-switch" code for 10% discount](https://www.packyapi.com/register?aff=cc-switch)
**ShanDianShuo** - Local-first AI voice input
[Free download](https://shandianshuo.cn) for Mac/Win
---
## Feedback & Support
- **Issues**: [GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **Discussions**: [GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **Documentation**: [README](../README.md)
- **Changelog**: [CHANGELOG.md](../CHANGELOG.md)
---
## What's Next
**v3.8.0 Preview** (Tentative):
- Local proxy functionality
Stay tuned for more updates!
---
**Happy Coding!**

View File

@@ -0,0 +1,481 @@
# CC Switch v3.7.1
> 稳定性增强与用户体验改进
**[English Version →](release-note-v3.7.1-en.md)**
---
## v3.7.1 更新内容
**发布日期**2025-11-22
**代码变更**17 个文件,+524 / -81 行
### Bug 修复
- **修复 Skills 第三方仓库安装失败** (#268)
修复使用自定义子目录的 skills 仓库无法安装的问题,支持类似 `ComposioHQ/awesome-claude-skills` 这样带子目录的仓库
- **修复 Gemini 配置持久化问题**
解决在 Gemini 表单中编辑 settings.json 后,切换供应商时修改丢失的问题
- **防止对话框意外关闭**
添加点击遮罩时的保护,避免误操作导致表单数据丢失,影响所有 11 个对话框组件
### 新增功能
- **Gemini 配置目录支持** (#255)
在设置中添加 Gemini 配置目录选项,支持自定义 `~/.gemini/` 路径
- **ArchLinux 安装支持** (#259)
添加 AUR 安装方式:`paru -S cc-switch-bin`
### 改进
- **Skills 错误消息国际化增强**
新增 28+ 条详细错误消息(中英文),提供具体的解决建议,下载超时从 15 秒延长到 60 秒
- **代码格式化**
应用统一的 Rust 和 TypeScript 代码格式化标准
### 下载
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载最新版本
---
## v3.7.0 完整更新说明
> 从供应商切换器到 AI CLI 一体化管理平台
**发布日期**2025-11-19
**提交数量**:从 v3.6.0 开始 85 个提交
**代码变更**152 个文件,+18,104 / -3,732 行
---
## 新增功能
### Gemini CLI 集成
完整支持 Google Gemini CLI成为第三个支持的应用Claude Code、Codex、Gemini
**核心能力**
- **双文件配置** - 同时支持 `.env``settings.json` 格式
- **自动检测** - 自动检测 `GOOGLE_GEMINI_BASE_URL``GEMINI_MODEL` 等环境变量
- **完整 MCP 支持** - 为 Gemini 提供完整的 MCP 服务器管理
- **深度链接集成** - 通过 `ccswitch://` 协议导入配置
- **系统托盘** - 从托盘菜单快速切换
**供应商预设**
- **Google Official** - 支持 OAuth 认证
- **PackyCode** - 合作伙伴集成
- **自定义** - 完全自定义支持
**技术实现**
- 新增后端模块:`gemini_config.rs`20KB`gemini_mcp.rs`
- 表单与环境编辑器同步
- 双文件原子写入
---
### MCP v3.7.0 统一架构
MCP 管理系统完整重构,实现跨应用统一管理。
**架构改进**
- **统一管理面板** - 单一界面管理 Claude/Codex/Gemini MCP 服务器
- **SSE 传输类型** - 新增 Server-Sent Events 支持
- **智能解析器** - 容错性 JSON 解析
- **格式修正** - 自动修复 Codex `[mcp_servers]` 格式
- **扩展字段** - 保留自定义 TOML 字段
**用户体验**
- 表单中的默认应用选择
- JSON 格式化器用于验证
- 改进的视觉层次
- 更好的错误消息
**导入/导出**
- 统一从三个应用导入
- 双向同步
- 状态保持
---
### Claude Skills 管理系统
**约 2,000 行代码** - 完整的技能生态平台。
**GitHub 集成**
- 从 GitHub 仓库自动扫描技能
- 预配置仓库:
- `ComposioHQ/awesome-claude-skills` - 精选集合
- `anthropics/skills` - Anthropic 官方技能
- `cexll/myclaude` - 社区贡献
- 添加自定义仓库
- 子目录扫描支持(`skillsPath`
**生命周期管理**
- **发现** - 自动检测 `SKILL.md` 文件
- **安装** - 一键安装到 `~/.claude/skills/`
- **卸载** - 安全移除并跟踪状态
- **更新** - 检查更新(基础设施已就绪)
**技术架构**
- **后端**`SkillService`526 行)集成 GitHub API
- **前端**SkillsPage、SkillCard、RepoManager
- **UI 组件**Badge、Card、Tableshadcn/ui
- **状态**:持久化存储在 `config.json`
- **国际化**47+ 个翻译键
---
### Prompts 管理系统
**约 1,300 行代码** - 完整的系统提示词管理。
**多预设管理**
- 创建无限数量的提示词预设
- 快速在预设间切换
- 同时只能激活一个提示词
- 活动提示词删除保护
**跨应用支持**
- **Claude**`~/.claude/CLAUDE.md`
- **Codex**`~/.codex/AGENTS.md`
- **Gemini**`~/.gemini/GEMINI.md`
**Markdown 编辑器**
- 完整的 CodeMirror 6 集成
- 语法高亮
- 暗色主题One Dark
- 实时预览
**智能同步**
- **自动写入** - 立即写入 live 文件
- **回填保护** - 切换前保存当前内容
- **自动导入** - 首次启动从 live 文件导入
- **修改保护** - 保留手动修改
**技术实现**
- **后端**`PromptService`213 行)
- **前端**PromptPanel177、PromptFormModal160、MarkdownEditor159
- **Hooks**usePromptActions152 行)
- **国际化**41+ 个翻译键
---
### 深度链接协议ccswitch://
通过 URL 方案一键导入供应商配置。
**功能特性**
- 所有平台的协议注册
- 从共享链接导入
- 生命周期集成
- 安全验证
---
### 环境变量冲突检测
智能检测和管理配置冲突。
**检测范围**
- **Claude & Codex** - 跨应用冲突
- **Gemini** - 自动发现
- **MCP** - 服务器配置冲突
**管理功能**
- 可视化冲突指示器
- 解决建议
- 覆盖警告
- 更改前备份
---
## 改进优化
### 供应商管理
**新增预设**
- **DouBaoSeed** - 字节跳动的豆包
- **Kimi For Coding** - 月之暗面
- **BaiLing** - 百灵 AI
- **移除 AnyRouter** - 避免误导
**增强功能**
- Codex 和 Gemini 的模型名称配置
- 供应商备注字段用于组织
- 增强的预设元数据
### 配置管理
- **通用配置迁移** - 从 localStorage 迁移到 `config.json`
- **统一持久化** - 跨所有应用共享
- **自动导入** - 首次启动配置导入
- **回填优先级** - 正确处理 live 文件
### UI/UX 改进
**设计系统**
- **macOS 原生** - 与系统对齐的配色方案
- **窗口居中** - 默认居中位置
- **视觉优化** - 改进的间距和层次
**交互优化**
- **密码输入** - 修复 Edge/IE 显示按钮
- **URL 溢出** - 修复卡片溢出
- **错误复制** - 可复制到剪贴板的错误
- **托盘同步** - 实时拖放同步
---
## Bug 修复
### 关键修复
- **用量脚本验证** - 边界检查
- **Gemini 验证** - 放宽约束
- **TOML 解析** - CJK 引号处理
- **MCP 字段** - 自定义字段保留
- **白屏** - FormLabel 崩溃修复
### 稳定性
- **托盘安全** - 模式匹配替代 unwrap
- **错误隔离** - 托盘失败不阻塞操作
- **导入分类** - 正确的类别分配
### UI 修复
- **模型占位符** - 移除误导性提示
- **Base URL** - 第三方供应商自动填充
- **拖拽排序** - 托盘菜单同步
---
## 技术改进
### 架构
**MCP v3.7.0**
- 移除遗留代码(约 1,000 行)
- 统一初始化结构
- 保持向后兼容性
- 全面的代码格式化
**平台兼容性**
- Windows winreg API 修复v0.52
- 安全模式匹配(无 `unwrap()`
- 跨平台托盘处理
### 配置
**同步机制**
- 跨所有应用的 MCP 同步
- Gemini 表单-编辑器同步
- 双文件读取(.env + settings.json
**验证增强**
- 输入边界检查
- TOML 引号规范化CJK
- 自定义字段保留
- 增强的错误消息
### 代码质量
**类型安全**
- 完整的 TypeScript 覆盖
- Rust 类型改进
- API 契约验证
**测试**
- 简化的断言
- 更好的测试覆盖
- 集成测试更新
**依赖项**
- Tauri 2.8.x
- Rust`anyhow``zip``serde_yaml``tempfile`
- 前端CodeMirror 6 包
- winreg 0.52Windows
---
## 技术统计
```
总体变更:
- 提交数85
- 文件数152 个文件变更
- 新增:+18,104 行
- 删除:-3,732 行
新增模块:
- Skills 管理2,034 行21 个文件)
- Prompts 管理1,302 行20 个文件)
- Gemini 集成:约 1,000 行
- MCP 重构:约 3,000 行重构
代码分布:
- 后端Rust约 4,500 行新增
- 前端React约 3,000 行新增
- 配置:约 1,500 行重构
- 测试:约 500 行
```
---
## 战略定位
### 从工具到平台
v3.7.0 代表了 CC Switch 定位的转变:
| 方面 | v3.6 | v3.7.0 |
| -------- | -------------- | ----------------------- |
| **身份** | 供应商切换器 | AI CLI 管理平台 |
| **范围** | 配置管理 | 生态系统管理 |
| **应用** | Claude + Codex | Claude + Codex + Gemini |
| **能力** | 切换配置 | 扩展能力Skills |
| **定制** | 手动编辑 | 可视化管理Prompts |
| **集成** | 孤立应用 | 统一管理MCP |
### AI CLI 管理六大支柱
1. **配置管理** - 供应商切换和管理
2. **能力扩展** - Skills 安装和生命周期
3. **行为定制** - 系统提示词预设
4. **生态集成** - 深度链接和共享
5. **多 AI 支持** - Claude/Codex/Gemini
6. **智能检测** - 冲突预防
---
## 下载与安装
### 系统要求
- **Windows**Windows 10+
- **macOS**macOS 10.15Catalina+
- **Linux**Ubuntu 22.04+ / Debian 11+ / Fedora 34+ / ArchLinux
### 下载链接
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载:
- **Windows**`CC-Switch-Windows.msi``-Portable.zip`
- **macOS**`CC-Switch-macOS.tar.gz``.zip`
- **Linux**`CC-Switch-Linux.AppImage``.deb`
- **ArchLinux**`paru -S cc-switch-bin`
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
---
## 迁移说明
### 从 v3.6.x 升级
**自动迁移** - 无需任何操作,配置完全兼容
### 从 v3.1.x 或更早版本升级
**需要两步迁移**
1. 首先升级到 v3.2.x执行一次性迁移
2. 然后升级到 v3.7.0
### 新功能
- **Skills**:无需迁移,全新开始
- **Prompts**:首次启动时从 live 文件自动导入
- **Gemini**:需要单独安装 Gemini CLI
- **MCP v3.7.0**:与之前的配置向后兼容
---
## 致谢
### 贡献者
感谢所有让这个版本成为可能的贡献者:
- [@YoVinchen](https://github.com/YoVinchen) - Skills & Prompts & Gemini 集成实现
- [@farion1231](https://github.com/farion1231) - 从开发沦为 issue 回复机
- 社区成员的测试和反馈
### 赞助商
**智谱AI** - GLM CODING PLAN 赞助商
[使用此链接购买可享九折优惠](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
**PackyCode** - API 中转服务合作伙伴
[使用 "cc-switch" 优惠码注册享 9 折优惠](https://www.packyapi.com/register?aff=cc-switch)
**闪电说** - 本地优先的 AI 语音输入法
[免费下载](https://shandianshuo.cn) Mac/Win 双平台
---
## 反馈与支持
- **问题反馈**[GitHub Issues](https://github.com/farion1231/cc-switch/issues)
- **讨论**[GitHub Discussions](https://github.com/farion1231/cc-switch/discussions)
- **文档**[README](../README_ZH.md)
- **更新日志**[CHANGELOG.md](../CHANGELOG.md)
---
## 未来展望
**v3.8.0 预览**(暂定):
- 本地代理功能
敬请期待更多更新!
---
**Happy Coding!**

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.7.0",
"version": "3.7.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"scripts": {
"dev": "pnpm tauri dev",
@@ -46,6 +46,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",

8
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
'@lobehub/icons-static-svg':
specifier: ^1.73.0
version: 1.73.0
'@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)
@@ -609,6 +612,9 @@ packages:
'@lezer/markdown@1.6.0':
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
'@lobehub/icons-static-svg@1.73.0':
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@@ -2839,6 +2845,8 @@ snapshots:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lobehub/icons-static-svg@1.73.0': {}
'@marijn/find-cluster-break@1.0.2': {}
'@mswjs/interceptors@0.40.0':

208
scripts/extract-icons.js Normal file
View File

@@ -0,0 +1,208 @@
const fs = require('fs');
const path = require('path');
// 要提取的图标列表(按分类组织)
const ICONS_TO_EXTRACT = {
// AI 服务商(必需)
aiProviders: [
'openai', 'anthropic', 'claude', 'google', 'gemini',
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
'cohere', 'perplexity', 'mistral', 'huggingface'
],
// 云平台
cloudPlatforms: [
'aws', 'azure', 'huawei', 'cloudflare'
],
// 开发工具
devTools: [
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
],
// 其他
others: [
'settings', 'folder', 'file', 'link'
]
};
// 合并所有图标
const ALL_ICONS = [
...ICONS_TO_EXTRACT.aiProviders,
...ICONS_TO_EXTRACT.cloudPlatforms,
...ICONS_TO_EXTRACT.devTools,
...ICONS_TO_EXTRACT.others
];
// 提取逻辑
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
// 确保输出目录存在
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log('🎨 CC-Switch Icon Extractor\n');
console.log('========================================');
console.log('📦 Extracting icons...\n');
let extracted = 0;
let notFound = [];
// 提取图标
ALL_ICONS.forEach(iconName => {
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
if (fs.existsSync(sourceFile)) {
fs.copyFileSync(sourceFile, targetFile);
console.log(`${iconName}.svg`);
extracted++;
} else {
console.log(`${iconName}.svg (not found)`);
notFound.push(iconName);
}
});
// 生成索引文件
console.log('\n📝 Generating index file...\n');
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${ALL_ICONS.filter(name => !notFound.includes(name))
.map(name => {
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
})
.join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
console.log('✓ Generated: src/icons/extracted/index.ts');
// 生成图标元数据
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
// AI Providers
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
// Cloud Platforms
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
// Dev Tools
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
// Others
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
console.log('✓ Generated: src/icons/extracted/metadata.ts');
// 生成 README
const readmeContent = `# Extracted Icons
This directory contains extracted icons from @lobehub/icons-static-svg.
## Statistics
- Total extracted: ${extracted} icons
- Not found: ${notFound.length} icons
## Extracted Icons
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
## Usage
\`\`\`typescript
import { getIcon, hasIcon, iconList } from './extracted';
// Get icon SVG
const svg = getIcon('openai');
// Check if icon exists
if (hasIcon('openai')) {
// ...
}
// Get all available icons
console.log(iconList);
\`\`\`
---
Last updated: ${new Date().toISOString()}
Generated by: scripts/extract-icons.js
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
console.log('✓ Generated: src/icons/extracted/README.md');
console.log('\n========================================');
console.log('✅ Extraction complete!\n');
console.log(` ✓ Extracted: ${extracted} icons`);
console.log(` ✗ Not found: ${notFound.length} icons`);
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
console.log('========================================\n');

95
scripts/filter-icons.js Normal file
View File

@@ -0,0 +1,95 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
// List of "Famous" icons to keep
// Based on common AI providers and tools
const KEEP_LIST = [
// AI Providers
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
'microsoft', 'azure', 'copilot', 'meta', 'llama',
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
'perplexity', 'huggingface', 'midjourney', 'stability',
'xai', 'grok', 'yi', 'zeroone', 'ollama',
// Cloud/Tools
'aws', 'googlecloud', 'huawei', 'cloudflare',
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
];
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Scanning ${files.length} files...`);
let keptCount = 0;
let deletedCount = 0;
let renamedCount = 0;
// First pass: Identify files to keep and prefer color versions
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
files.forEach(file => {
const isColor = file.endsWith('-color.svg');
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
if (!fileMap[baseName]) {
fileMap[baseName] = { hasColor: false, hasMono: false };
}
if (isColor) {
fileMap[baseName].hasColor = true;
} else {
fileMap[baseName].hasMono = true;
}
});
// Second pass: Process files
Object.keys(fileMap).forEach(baseName => {
const info = fileMap[baseName];
const shouldKeep = KEEP_LIST.includes(baseName);
if (!shouldKeep) {
// Delete both versions if not in keep list
if (info.hasColor) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
deletedCount++;
}
if (info.hasMono) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
deletedCount++;
}
return;
}
// If keeping, prefer color
if (info.hasColor) {
// Rename color version to base version (overwrite mono if exists)
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
try {
// If mono exists, it will be overwritten/replaced
fs.renameSync(colorPath, targetPath);
renamedCount++;
keptCount++;
} catch (e) {
console.error(`Error renaming ${baseName}:`, e);
}
} else if (info.hasMono) {
// Keep mono if no color version
keptCount++;
}
});
console.log(`\nCleanup complete:`);
console.log(`- Kept: ${keptCount}`);
console.log(`- Deleted: ${deletedCount}`);
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
// Regenerate index and metadata
require('./generate-icon-index.js');

View File

@@ -0,0 +1,113 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
const INDEX_FILE = path.join(ICONS_DIR, 'index.ts');
const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');
// Known metadata from previous configuration
const KNOWN_METADATA = {
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Found ${files.length} SVG files.`);
// Generate index.ts
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${files.map(file => {
const name = path.basename(file, '.svg');
const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
}).join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(INDEX_FILE, indexContent);
console.log(`Generated ${INDEX_FILE}`);
// Generate metadata.ts
const metadataEntries = files.map(file => {
const name = path.basename(file, '.svg').toLowerCase();
const known = KNOWN_METADATA[name];
if (known) {
return ` ${name}: ${JSON.stringify(known)},`;
}
// Default metadata for unknown icons
return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;
});
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
${metadataEntries.join('\n')}
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(METADATA_FILE, metadataContent);
console.log(`Generated ${METADATA_FILE}`);

116
src-tauri/Cargo.lock generated
View File

@@ -39,6 +39,18 @@ dependencies = [
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -291,6 +303,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -595,18 +618,23 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.7.0"
version = "3.7.1"
dependencies = [
"anyhow",
"auto-launch",
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures",
"indexmap 2.11.4",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"once_cell",
"regex",
"reqwest",
"rquickjs",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
@@ -982,6 +1010,15 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
@@ -1000,6 +1037,17 @@ dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
@@ -1241,6 +1289,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1779,7 +1839,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
"ahash 0.7.8",
]
[[package]]
@@ -1787,6 +1847,9 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
name = "hashbrown"
@@ -1794,6 +1857,15 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -2364,6 +2436,17 @@ dependencies = [
"redox_syscall",
]
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -3815,6 +3898,20 @@ dependencies = [
"cc",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags 2.9.4",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
@@ -5533,6 +5630,12 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.0"
@@ -6397,6 +6500,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.7.0"
version = "3.7.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -48,6 +48,11 @@ zip = "2.2"
serde_yaml = "0.9"
tempfile = "3"
url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled"] }
indexmap = { version = "2", features = ["serde"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"

View File

@@ -10,7 +10,8 @@
"opener:default",
"updater:default",
"core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging",
"process:allow-restart",
"dialog:default"
]
}
}

View File

@@ -0,0 +1,40 @@
use crate::error::AppError;
use auto_launch::AutoLaunch;
/// 初始化 AutoLaunch 实例
fn get_auto_launch() -> Result<AutoLaunch, AppError> {
let app_name = "CC Switch";
let app_path =
std::env::current_exe().map_err(|e| AppError::Message(format!("无法获取应用路径: {e}")))?;
let auto_launch = AutoLaunch::new(app_name, &app_path.to_string_lossy(), false, &[] as &[&str]);
Ok(auto_launch)
}
/// 启用开机自启
pub fn enable_auto_launch() -> Result<(), AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.enable()
.map_err(|e| AppError::Message(format!("启用开机自启失败: {e}")))?;
log::info!("已启用开机自启");
Ok(())
}
/// 禁用开机自启
pub fn disable_auto_launch() -> Result<(), AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.disable()
.map_err(|e| AppError::Message(format!("禁用开机自启失败: {e}")))?;
log::info!("已禁用开机自启");
Ok(())
}
/// 检查是否已启用开机自启
pub fn is_auto_launch_enabled() -> Result<bool, AppError> {
let auto_launch = get_auto_launch()?;
auto_launch
.is_enabled()
.map_err(|e| AppError::Message(format!("检查开机自启状态失败: {e}")))
}

View File

@@ -29,6 +29,7 @@ pub fn get_codex_config_path() -> PathBuf {
}
/// 获取 Codex 供应商配置文件路径
#[allow(dead_code)]
pub fn get_codex_provider_paths(
provider_id: &str,
provider_name: Option<&str>,
@@ -44,6 +45,7 @@ pub fn get_codex_provider_paths(
}
/// 删除 Codex 供应商配置文件
#[allow(dead_code)]
pub fn delete_codex_provider_config(
provider_id: &str,
provider_name: &str,

View File

@@ -141,11 +141,10 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
pub async fn get_claude_common_config_snippet(
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.claude.clone())
state
.db
.get_config_snippet("claude")
.map_err(|e| e.to_string())
}
/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet
@@ -154,24 +153,22 @@ pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证是否为有效的 JSON如果不为空
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
guard.common_config_snippets.claude = if snippet.trim().is_empty() {
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
guard.save().map_err(|e| e.to_string())?;
state
.db
.set_config_snippet("claude", value)
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -181,17 +178,10 @@ pub async fn get_common_config_snippet(
app_type: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<Option<String>, String> {
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let guard = state
.config
.read()
.map_err(|e| format!("读取配置锁失败: {e}"))?;
Ok(guard.common_config_snippets.get(&app).cloned())
state
.db
.get_config_snippet(&app_type)
.map_err(|e| e.to_string())
}
/// 设置通用配置片段(统一接口)
@@ -201,40 +191,31 @@ pub async fn set_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
use crate::app_config::AppType;
use std::str::FromStr;
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
let mut guard = state
.config
.write()
.map_err(|e| format!("写入配置锁失败: {e}"))?;
// 验证格式(根据应用类型)
if !snippet.trim().is_empty() {
match app {
AppType::Claude | AppType::Gemini => {
match app_type.as_str() {
"claude" | "gemini" => {
// 验证 JSON 格式
serde_json::from_str::<serde_json::Value>(&snippet)
.map_err(|e| format!("无效的 JSON 格式: {e}"))?;
}
AppType::Codex => {
"codex" => {
// TOML 格式暂不验证(或可使用 toml crate
// 注意TOML 验证较为复杂,暂时跳过
}
_ => {}
}
}
guard.common_config_snippets.set(
&app,
if snippet.trim().is_empty() {
None
} else {
Some(snippet)
},
);
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
guard.save().map_err(|e| e.to_string())?;
state
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@@ -9,6 +9,16 @@ pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
parse_deeplink_url(&url).map_err(|e| e.to_string())
}
/// Merge configuration from Base64/URL into a deep link request
/// This is used by the frontend to show the complete configuration in the confirmation dialog
#[tauri::command]
pub fn merge_deeplink_config(
request: DeepLinkImportRequest,
) -> Result<DeepLinkImportRequest, String> {
log::info!("Merging config for deep link request: {}", request.name);
crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string())
}
/// Import a provider from a deep link request (after user confirmation)
#[tauri::command]
pub fn import_from_deeplink(

View File

@@ -29,11 +29,17 @@ pub async fn export_config_to_file(
}
/// 从文件导入配置
/// TODO: 需要重构以使用数据库而不是 JSON 配置
#[tauri::command]
pub async fn import_config_from_file(
#[allow(non_snake_case)] filePath: String,
state: State<'_, AppState>,
#[allow(non_snake_case)] _filePath: String,
_state: State<'_, AppState>,
) -> Result<Value, String> {
// TODO: 实现基于数据库的导入逻辑
// 当前暂时禁用此功能
Err("配置导入功能正在重构中,暂时不可用".to_string())
/* 旧的实现,需要重构:
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
ConfigService::load_config_for_import(&path_buf)
@@ -55,11 +61,18 @@ pub async fn import_config_from_file(
"message": "Configuration imported successfully",
"backupId": backup_id
}))
*/
}
/// 同步当前供应商配置到对应的 live 文件
/// TODO: 需要重构以使用数据库而不是 JSON 配置
#[tauri::command]
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
pub async fn sync_current_providers_live(_state: State<'_, AppState>) -> Result<Value, String> {
// TODO: 实现基于数据库的同步逻辑
// 当前暂时禁用此功能
Err("配置同步功能正在重构中,暂时不可用".to_string())
/* 旧的实现,需要重构:
{
let mut config_state = state
.config
@@ -73,6 +86,7 @@ pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<V
"success": true,
"message": "Live configuration synchronized"
}))
*/
}
/// 保存文件对话框

View File

@@ -1,5 +1,6 @@
#![allow(non_snake_case)]
use indexmap::IndexMap;
use std::collections::HashMap;
use serde::Serialize;
@@ -82,12 +83,8 @@ pub async fn upsert_mcp_server_in_config(
// 读取现有的服务器(如果存在)
let existing_server = {
let cfg = state.config.read().map_err(|e| e.to_string())?;
if let Some(servers) = &cfg.mcp.servers {
servers.get(&id).cloned()
} else {
None
}
let servers = state.db.get_all_mcp_servers().map_err(|e| e.to_string())?;
servers.get(&id).cloned()
};
// 构建新的统一服务器结构
@@ -165,7 +162,7 @@ use crate::app_config::McpServer;
#[tauri::command]
pub async fn get_mcp_servers(
state: State<'_, AppState>,
) -> Result<HashMap<String, McpServer>, String> {
) -> Result<IndexMap<String, McpServer>, String> {
McpService::get_all_servers(&state).map_err(|e| e.to_string())
}

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use std::str::FromStr;
use tauri::State;
@@ -12,7 +12,7 @@ use crate::store::AppState;
pub async fn get_prompts(
app: String,
state: State<'_, AppState>,
) -> Result<HashMap<String, Prompt>, String> {
) -> Result<IndexMap<String, Prompt>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())
}

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use tauri::State;
use crate::app_config::AppType;
@@ -13,7 +13,7 @@ use std::str::FromStr;
pub fn get_providers(
state: State<'_, AppState>,
app: String,
) -> Result<HashMap<String, Provider>, String> {
) -> Result<IndexMap<String, Provider>, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string())
}

View File

@@ -37,3 +37,20 @@ pub async fn set_app_config_dir_override(
crate::app_store::set_app_config_dir_to_store(&app, path.as_deref())?;
Ok(true)
}
/// 设置开机自启
#[tauri::command]
pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
if enabled {
crate::auto_launch::enable_auto_launch().map_err(|e| format!("启用开机自启失败: {e}"))?;
} else {
crate::auto_launch::disable_auto_launch().map_err(|e| format!("禁用开机自启失败: {e}"))?;
}
Ok(true)
}
/// 获取开机自启状态
#[tauri::command]
pub async fn get_auto_launch_status() -> Result<bool, String> {
crate::auto_launch::is_auto_launch_enabled().map_err(|e| format!("获取开机自启状态失败: {e}"))
}

View File

@@ -1,3 +1,4 @@
use crate::error::format_skill_error;
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState;
@@ -12,10 +13,7 @@ pub async fn get_skills(
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
let repos = {
let config = app_state.config.read().map_err(|e| e.to_string())?;
config.skills.repos.clone()
};
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
@@ -31,10 +29,7 @@ pub async fn install_skill(
app_state: State<'_, AppState>,
) -> Result<bool, String> {
// 先在不持有写锁的情况下收集仓库与技能信息
let repos = {
let config = app_state.config.read().map_err(|e| e.to_string())?;
config.skills.repos.clone()
};
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service
.0
@@ -45,18 +40,30 @@ pub async fn install_skill(
let skill = skills
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| "技能不存在".to_string())?;
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
&[("directory", &directory)],
Some("checkRepoUrl"),
)
})?;
if !skill.installed {
let repo = SkillRepo {
owner: skill
.repo_owner
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
name: skill
.repo_name
.clone()
.ok_or_else(|| "缺少仓库信息".to_string())?,
owner: skill.repo_owner.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "owner")],
None,
)
})?,
name: skill.repo_name.clone().ok_or_else(|| {
format_skill_error(
"MISSING_REPO_INFO",
&[("directory", &directory), ("field", "name")],
None,
)
})?,
branch: skill
.repo_branch
.clone()
@@ -72,19 +79,16 @@ pub async fn install_skill(
.map_err(|e| e.to_string())?;
}
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
config.skills.skills.insert(
directory.clone(),
SkillState {
app_state
.db
.update_skill_state(
&directory,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
);
}
app_state.save().map_err(|e| e.to_string())?;
)
.map_err(|e| e.to_string())?;
Ok(true)
}
@@ -100,13 +104,17 @@ pub fn uninstall_skill(
.uninstall_skill(directory.clone())
.map_err(|e| e.to_string())?;
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
config.skills.skills.remove(&directory);
}
app_state.save().map_err(|e| e.to_string())?;
// Remove from database by setting installed = false
app_state
.db
.update_skill_state(
&directory,
&SkillState {
installed: false,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
Ok(true)
}
@@ -116,28 +124,19 @@ pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
let config = app_state.config.read().map_err(|e| e.to_string())?;
Ok(config.skills.repos.clone())
app_state.db.get_skill_repos().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_skill_repo(
repo: SkillRepo,
service: State<'_, SkillServiceState>,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
service
.0
.add_repo(&mut config.skills, repo)
.map_err(|e| e.to_string())?;
}
app_state.save().map_err(|e| e.to_string())?;
app_state
.db
.save_skill_repo(&repo)
.map_err(|e| e.to_string())?;
Ok(true)
}
@@ -145,19 +144,12 @@ pub fn add_skill_repo(
pub fn remove_skill_repo(
owner: String,
name: String,
service: State<'_, SkillServiceState>,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
{
let mut config = app_state.config.write().map_err(|e| e.to_string())?;
service
.0
.remove_repo(&mut config.skills, owner, name)
.map_err(|e| e.to_string())?;
}
app_state.save().map_err(|e| e.to_string())?;
app_state
.db
.delete_skill_repo(&owner, &name)
.map_err(|e| e.to_string())?;
Ok(true)
}

View File

@@ -79,6 +79,7 @@ pub fn get_app_config_path() -> PathBuf {
}
/// 清理供应商名称,确保文件名安全
#[allow(dead_code)]
pub fn sanitize_provider_name(name: &str) -> String {
name.chars()
.map(|c| match c {
@@ -90,6 +91,7 @@ pub fn sanitize_provider_name(name: &str) -> String {
}
/// 获取供应商配置文件路径
#[allow(dead_code)]
pub fn get_provider_config_path(provider_id: &str, provider_name: Option<&str>) -> PathBuf {
let base_name = provider_name
.map(sanitize_provider_name)

846
src-tauri/src/database.rs Normal file
View File

@@ -0,0 +1,846 @@
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
use crate::config::get_app_config_dir;
use crate::error::AppError;
use crate::prompt::Prompt;
use crate::provider::{Provider, ProviderMeta};
use crate::services::skill::{SkillRepo, SkillState};
use indexmap::IndexMap;
use rusqlite::{params, Connection, Result};
use std::collections::HashMap;
use std::sync::Mutex;
pub struct Database {
// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State中共享
// rusqlite::Connection 本身不是 Sync 的
conn: Mutex<Connection>,
}
impl Database {
/// 初始化数据库连接并创建表
pub fn init() -> Result<Self, AppError> {
let db_path = get_app_config_dir().join("cc-switch.db");
// 确保父目录存在
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?;
// 启用外键约束
conn.execute("PRAGMA foreign_keys = ON;", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let db = Self {
conn: Mutex::new(conn),
};
db.create_tables()?;
Ok(db)
}
fn create_tables(&self) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
// 1. Providers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL,
website_url TEXT,
category TEXT,
created_at INTEGER,
sort_index INTEGER,
notes TEXT,
icon TEXT,
icon_color TEXT,
meta TEXT,
is_current BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 2. Provider Endpoints 表
conn.execute(
"CREATE TABLE IF NOT EXISTS provider_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
url TEXT NOT NULL,
added_at INTEGER,
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
[],
).map_err(|e| AppError::Database(e.to_string()))?;
// 3. MCP Servers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL,
description TEXT,
homepage TEXT,
docs TEXT,
tags TEXT,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 4. Prompts 表
conn.execute(
"CREATE TABLE IF NOT EXISTS prompts (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL,
description TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (id, app_type)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skills (
key TEXT PRIMARY KEY,
installed BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 6. Skill Repos 表
conn.execute(
"CREATE TABLE IF NOT EXISTS skill_repos (
owner TEXT NOT NULL,
name TEXT NOT NULL,
branch TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1,
skills_path TEXT,
PRIMARY KEY (owner, name)
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 7. Settings 表 (通用配置)
conn.execute(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 从 MultiAppConfig 迁移数据
pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {
let mut conn = self.conn.lock().unwrap();
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// 1. 迁移 Providers
for (app_key, manager) in &config.apps {
let app_type = app_key; // "claude", "codex", "gemini"
let current_id = &manager.current;
for (id, provider) in &manager.providers {
let is_current = if id == current_id { 1 } else { 0 };
// 处理 meta 和 endpoints
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
tx.execute(
"INSERT OR REPLACE 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)",
params![
id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), // 不含 endpoints 的 meta
is_current,
],
)
.map_err(|e| AppError::Database(format!("Migrate provider failed: {e}")))?;
// 迁移 Endpoints
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![id, app_type, url, endpoint.added_at],
)
.map_err(|e| AppError::Database(format!("Migrate endpoint failed: {e}")))?;
}
}
}
// 2. 迁移 MCP Servers
if let Some(servers) = &config.mcp.servers {
for (id, server) in servers {
tx.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
id,
server.name,
serde_json::to_string(&server.server).unwrap(),
server.description,
server.homepage,
server.docs,
serde_json::to_string(&server.tags).unwrap(),
server.apps.claude,
server.apps.codex,
server.apps.gemini,
],
)
.map_err(|e| AppError::Database(format!("Migrate mcp server failed: {e}")))?;
}
}
// 3. 迁移 Prompts
let migrate_prompts =
|prompts_map: &std::collections::HashMap<String, crate::prompt::Prompt>,
app_type: &str|
-> Result<(), AppError> {
for (id, prompt) in prompts_map {
tx.execute(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
id,
app_type,
prompt.name,
prompt.content,
prompt.description,
prompt.enabled,
prompt.created_at,
prompt.updated_at,
],
)
.map_err(|e| AppError::Database(format!("Migrate prompt failed: {e}")))?;
}
Ok(())
};
migrate_prompts(&config.prompts.claude.prompts, "claude")?;
migrate_prompts(&config.prompts.codex.prompts, "codex")?;
migrate_prompts(&config.prompts.gemini.prompts, "gemini")?;
// 4. 迁移 Skills
for (key, state) in &config.skills.skills {
tx.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?;
}
for repo in &config.skills.repos {
tx.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
).map_err(|e| AppError::Database(format!("Migrate skill repo failed: {e}")))?;
}
// 5. 迁移 Common Config
if let Some(snippet) = &config.common_config_snippets.claude {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_claude", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.codex {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_codex", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
if let Some(snippet) = &config.common_config_snippets.gemini {
tx.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params!["common_config_gemini", snippet],
)
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
}
tx.commit()
.map_err(|e| AppError::Database(format!("Commit migration failed: {e}")))?;
Ok(())
}
// --- Providers DAO ---
pub fn get_all_providers(
&self,
app_type: &str,
) -> Result<IndexMap<String, Provider>, AppError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
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()))?;
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 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(), // Placeholder, set below
name,
settings_config,
website_url,
category,
created_at,
sort_index,
notes,
meta: Some(meta),
icon,
icon_color,
},
))
})
.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();
// Load 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_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 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)
}
}
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = self.conn.lock().unwrap();
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// Handle meta and endpoints
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// Check if it exists to preserve is_current
let is_current: bool = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
)
.unwrap_or(false);
tx.execute(
"INSERT OR REPLACE 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)",
params![
provider.id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
provider.website_url,
provider.category,
provider.created_at,
provider.sort_index,
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// Sync endpoints: Delete all and re-insert
tx.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2",
params![provider.id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
for (url, endpoint) in endpoints {
tx.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![provider.id, app_type, url, endpoint.added_at],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
tx.commit().map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = self.conn.lock().unwrap();
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
// Reset all to 0
tx.execute(
"UPDATE providers SET is_current = 0 WHERE app_type = ?1",
params![app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// Set new current
tx.execute(
"UPDATE providers SET is_current = 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 fn add_custom_endpoint(
&self,
app_type: &str,
provider_id: &str,
url: &str,
) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let added_at = chrono::Utc::now().timestamp_millis();
conn.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
params![provider_id, app_type, url, added_at],
).map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn remove_custom_endpoint(
&self,
app_type: &str,
provider_id: &str,
url: &str,
) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
params![provider_id, app_type, url],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// --- MCP Servers DAO ---
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
let server_iter = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let server_config_str: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let homepage: Option<String> = row.get(4)?;
let docs: Option<String> = row.get(5)?;
let tags_str: String = row.get(6)?;
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
Ok((
id.clone(),
McpServer {
id,
name,
server,
apps: McpApps {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
},
description,
homepage,
docs,
tags,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut servers = IndexMap::new();
for server_res in server_iter {
let (id, server) = server_res.map_err(|e| AppError::Database(e.to_string()))?;
servers.insert(id, server);
}
Ok(servers)
}
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
server.id,
server.name,
serde_json::to_string(&server.server).unwrap(),
server.description,
server.homepage,
server.docs,
serde_json::to_string(&server.tags).unwrap(),
server.apps.claude,
server.apps.codex,
server.apps.gemini,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// --- Prompts DAO ---
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, name, content, description, enabled, created_at, updated_at
FROM prompts WHERE app_type = ?1
ORDER BY created_at ASC, id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let prompt_iter = stmt
.query_map(params![app_type], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let content: String = row.get(2)?;
let description: Option<String> = row.get(3)?;
let enabled: bool = row.get(4)?;
let created_at: Option<i64> = row.get(5)?;
let updated_at: Option<i64> = row.get(6)?;
Ok((
id.clone(),
Prompt {
id,
name,
content,
description,
enabled,
created_at,
updated_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut prompts = IndexMap::new();
for prompt_res in prompt_iter {
let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?;
prompts.insert(id, prompt);
}
Ok(prompts)
}
pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
prompt.id,
app_type,
prompt.name,
prompt.content,
prompt.description,
prompt.enabled,
prompt.created_at,
prompt.updated_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// --- Skills DAO ---
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |row| {
let key: String = row.get(0)?;
let installed: bool = row.get(1)?;
let installed_at_ts: i64 = row.get(2)?;
let installed_at =
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default();
Ok((
key,
SkillState {
installed,
installed_at,
},
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut skills = IndexMap::new();
for skill_res in skill_iter {
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill);
}
Ok(skills)
}
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
let repo_iter = stmt
.query_map([], |row| {
Ok(SkillRepo {
owner: row.get(0)?,
name: row.get(1)?,
branch: row.get(2)?,
enabled: row.get(3)?,
skills_path: row.get(4)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut repos = Vec::new();
for repo_res in repo_iter {
repos.push(repo_res.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(repos)
}
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
).map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
params![owner, name],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// --- Settings DAO ---
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?1")
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query(params![key])
.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)
}
}
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
// --- Config Snippets Helper Methods ---
pub fn get_config_snippet(&self, app_type: &str) -> Result<Option<String>, AppError> {
self.get_setting(&format!("common_config_{app_type}"))
}
pub fn set_config_snippet(
&self,
app_type: &str,
snippet: Option<String>,
) -> Result<(), AppError> {
let key = format!("common_config_{app_type}");
if let Some(value) = snippet {
self.set_setting(&key, &value)
} else {
// Delete if None
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
}

View File

@@ -37,6 +37,24 @@ pub struct DeepLinkImportRequest {
/// Optional notes/description
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// Optional Haiku model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub haiku_model: Option<String>,
/// Optional Sonnet model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub sonnet_model: Option<String>,
/// Optional Opus model (Claude only, v3.7.1+)
#[serde(skip_serializing_if = "Option::is_none")]
pub opus_model: Option<String>,
/// Optional Base64 encoded config content (v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<String>,
/// Optional config format (json/toml, v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config_format: Option<String>,
/// Optional remote config URL (v3.8+)
#[serde(skip_serializing_if = "Option::is_none")]
pub config_url: Option<String>,
}
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
@@ -110,29 +128,33 @@ pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppErr
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
.clone();
let homepage = params
.get("homepage")
.ok_or_else(|| AppError::InvalidInput("Missing 'homepage' parameter".to_string()))?
.clone();
// Make these optional for config file auto-fill (v3.8+)
let homepage = params.get("homepage").cloned().unwrap_or_default();
let endpoint = params.get("endpoint").cloned().unwrap_or_default();
let api_key = params.get("apiKey").cloned().unwrap_or_default();
let endpoint = params
.get("endpoint")
.ok_or_else(|| AppError::InvalidInput("Missing 'endpoint' parameter".to_string()))?
.clone();
let api_key = params
.get("apiKey")
.ok_or_else(|| AppError::InvalidInput("Missing 'apiKey' parameter".to_string()))?
.clone();
// Validate URLs
validate_url(&homepage, "homepage")?;
validate_url(&endpoint, "endpoint")?;
// Validate URLs only if provided
if !homepage.is_empty() {
validate_url(&homepage, "homepage")?;
}
if !endpoint.is_empty() {
validate_url(&endpoint, "endpoint")?;
}
// Extract optional fields
let model = params.get("model").cloned();
let notes = params.get("notes").cloned();
// Extract Claude-specific optional model fields (v3.7.1+)
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
let opus_model = params.get("opusModel").cloned();
// Extract optional config fields (v3.8+)
let config = params.get("config").cloned();
let config_format = params.get("configFormat").cloned();
let config_url = params.get("configUrl").cloned();
Ok(DeepLinkImportRequest {
version,
resource,
@@ -143,6 +165,12 @@ pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppErr
api_key,
model,
notes,
haiku_model,
sonnet_model,
opus_model,
config,
config_format,
config_url,
})
}
@@ -165,23 +193,44 @@ fn validate_url(url_str: &str, field_name: &str) -> Result<(), AppError> {
///
/// This function:
/// 1. Validates the request
/// 2. Converts it to a Provider structure
/// 3. Delegates to ProviderService for actual import
/// 2. Merges config file if provided (v3.8+)
/// 3. Converts it to a Provider structure
/// 4. Delegates to ProviderService for actual import
pub fn import_provider_from_deeplink(
state: &AppState,
request: DeepLinkImportRequest,
) -> Result<String, AppError> {
// Step 1: Merge config file if provided (v3.8+)
let merged_request = parse_and_merge_config(&request)?;
// Step 2: Validate required fields after merge
if merged_request.api_key.is_empty() {
return Err(AppError::InvalidInput(
"API key is required (either in URL or config file)".to_string(),
));
}
if merged_request.endpoint.is_empty() {
return Err(AppError::InvalidInput(
"Endpoint is required (either in URL or config file)".to_string(),
));
}
if merged_request.homepage.is_empty() {
return Err(AppError::InvalidInput(
"Homepage is required (either in URL or config file)".to_string(),
));
}
// Parse app type
let app_type = AppType::from_str(&request.app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", request.app)))?;
let app_type = AppType::from_str(&merged_request.app)
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {}", merged_request.app)))?;
// Build provider configuration based on app type
let mut provider = build_provider_from_request(&app_type, &request)?;
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
// Generate a unique ID for the provider using timestamp + sanitized name
// This is similar to how frontend generates IDs
let timestamp = chrono::Utc::now().timestamp_millis();
let sanitized_name = request
let sanitized_name = merged_request
.name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
@@ -211,11 +260,31 @@ fn build_provider_from_request(
env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key));
env.insert("ANTHROPIC_BASE_URL".to_string(), json!(request.endpoint));
// Add model if provided (use as default model)
// Add default model if provided
if let Some(model) = &request.model {
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
}
// Add Claude-specific model fields (v3.7.1+)
if let Some(haiku_model) = &request.haiku_model {
env.insert(
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
json!(haiku_model),
);
}
if let Some(sonnet_model) = &request.sonnet_model {
env.insert(
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
json!(sonnet_model),
);
}
if let Some(opus_model) = &request.opus_model {
env.insert(
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
json!(opus_model),
);
}
json!({ "env": env })
}
AppType::Codex => {
@@ -319,11 +388,254 @@ requires_openai_auth = true
sort_index: None,
notes: request.notes.clone(),
meta: None,
icon: None,
icon_color: None,
};
Ok(provider)
}
/// Parse and merge configuration from Base64 encoded config or remote URL
///
/// Priority: URL params > inline config > remote config
pub fn parse_and_merge_config(
request: &DeepLinkImportRequest,
) -> Result<DeepLinkImportRequest, AppError> {
use base64::prelude::*;
// If no config provided, return original request
if request.config.is_none() && request.config_url.is_none() {
return Ok(request.clone());
}
// Step 1: Get config content
let config_content = if let Some(config_b64) = &request.config {
// Decode Base64 inline config
let decoded = BASE64_STANDARD
.decode(config_b64)
.map_err(|e| AppError::InvalidInput(format!("Invalid Base64 encoding: {e}")))?;
String::from_utf8(decoded)
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
} else if let Some(_config_url) = &request.config_url {
// Fetch remote config (TODO: implement remote fetching in next phase)
return Err(AppError::InvalidInput(
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
));
} else {
return Ok(request.clone());
};
// Step 2: Parse config based on format
let format = request.config_format.as_deref().unwrap_or("json");
let config_value: serde_json::Value = match format {
"json" => serde_json::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
"toml" => {
let toml_value: toml::Value = toml::from_str(&config_content)
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
// Convert TOML to JSON for uniform processing
serde_json::to_value(toml_value)
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
}
_ => {
return Err(AppError::InvalidInput(format!(
"Unsupported config format: {format}"
)))
}
};
// Step 3: Extract values from config based on app type and merge with URL params
let mut merged = request.clone();
match request.app.as_str() {
"claude" => merge_claude_config(&mut merged, &config_value)?,
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
_ => {
return Err(AppError::InvalidInput(format!(
"Invalid app type: {}",
request.app
)))
}
}
Ok(merged)
}
/// Merge Claude configuration from config file
///
/// Priority: URL params override config file values
fn merge_claude_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
let env = config
.get("env")
.and_then(|v| v.as_object())
.ok_or_else(|| {
AppError::InvalidInput("Claude config must have 'env' object".to_string())
})?;
// Auto-fill API key if not provided in URL
if request.api_key.is_empty() {
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
request.api_key = token.to_string();
}
}
// Auto-fill endpoint if not provided in URL
if request.endpoint.is_empty() {
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = base_url.to_string();
}
}
// Auto-fill homepage from endpoint if not provided
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://anthropic.com".to_string());
}
// Auto-fill model fields (URL params take priority)
if request.model.is_none() {
request.model = env
.get("ANTHROPIC_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.haiku_model.is_none() {
request.haiku_model = env
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.sonnet_model.is_none() {
request.sonnet_model = env
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
if request.opus_model.is_none() {
request.opus_model = env
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
Ok(())
}
/// Merge Codex configuration from config file
fn merge_codex_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Auto-fill API key from auth.OPENAI_API_KEY
if request.api_key.is_empty() {
if let Some(api_key) = config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str())
{
request.api_key = api_key.to_string();
}
}
// Auto-fill endpoint and model from config string
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
// Parse TOML config string to extract base_url and model
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
// Extract base_url from model_providers section
if request.endpoint.is_empty() {
if let Some(base_url) = extract_codex_base_url(&toml_value) {
request.endpoint = base_url;
}
}
// Extract model
if request.model.is_none() {
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
request.model = Some(model.to_string());
}
}
}
}
// Auto-fill homepage from endpoint
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://openai.com".to_string());
}
Ok(())
}
/// Merge Gemini configuration from config file
fn merge_gemini_config(
request: &mut DeepLinkImportRequest,
config: &serde_json::Value,
) -> Result<(), AppError> {
// Gemini uses flat env structure
if request.api_key.is_empty() {
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
request.api_key = api_key.to_string();
}
}
if request.endpoint.is_empty() {
if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) {
request.endpoint = base_url.to_string();
}
}
if request.model.is_none() {
request.model = config
.get("GEMINI_MODEL")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
// Auto-fill homepage from endpoint
if request.homepage.is_empty() && !request.endpoint.is_empty() {
request.homepage = infer_homepage_from_endpoint(&request.endpoint)
.unwrap_or_else(|| "https://ai.google.dev".to_string());
}
Ok(())
}
/// Extract base_url from Codex TOML config
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
// Try to find base_url in model_providers section
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
for (_key, provider) in providers.iter() {
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
return Some(base_url.to_string());
}
}
}
None
}
/// Infer homepage URL from API endpoint
///
/// Examples:
/// - https://api.anthropic.com/v1 → https://anthropic.com
/// - https://api.openai.com/v1 → https://openai.com
/// - https://api-test.company.com/v1 → https://company.com
fn infer_homepage_from_endpoint(endpoint: &str) -> Option<String> {
let url = Url::parse(endpoint).ok()?;
let host = url.host_str()?;
// Remove common API prefixes
let clean_host = host
.strip_prefix("api.")
.or_else(|| host.strip_prefix("api-"))
.unwrap_or(host);
Some(format!("https://{clean_host}"))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -375,14 +687,15 @@ mod tests {
#[test]
fn test_parse_missing_required_field() {
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test";
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
let url = "ccswitch://v1/import?resource=provider&app=claude";
let result = parse_deeplink_url(url);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing 'homepage' parameter"));
.contains("Missing 'name' parameter"));
}
#[test]
@@ -413,6 +726,12 @@ mod tests {
api_key: "test-api-key".to_string(),
model: Some("gemini-2.0-flash".to_string()),
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -443,6 +762,12 @@ mod tests {
api_key: "test-api-key".to_string(),
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
};
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
@@ -454,4 +779,88 @@ mod tests {
// Model should not be present
assert!(env.get("GEMINI_MODEL").is_none());
}
#[test]
fn test_infer_homepage() {
assert_eq!(
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
Some("https://anthropic.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
Some("https://test.company.com".to_string())
);
assert_eq!(
infer_homepage_from_endpoint("https://example.com"),
Some("https://example.com".to_string())
);
}
#[test]
fn test_parse_and_merge_config_claude() {
use base64::prelude::*;
// Prepare Base64 encoded Claude config
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "claude".to_string(),
name: "Test".to_string(),
homepage: String::new(),
endpoint: String::new(),
api_key: String::new(),
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// Should auto-fill from config
assert_eq!(merged.api_key, "sk-ant-xxx");
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
assert_eq!(merged.homepage, "https://anthropic.com");
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
}
#[test]
fn test_parse_and_merge_config_url_override() {
use base64::prelude::*;
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
let request = DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: "claude".to_string(),
name: "Test".to_string(),
homepage: String::new(),
endpoint: String::new(),
api_key: "sk-new".to_string(), // URL param should override
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: Some(config_b64),
config_format: Some("json".to_string()),
config_url: None,
};
let merged = parse_and_merge_config(&request).unwrap();
// URL param should take priority
assert_eq!(merged.api_key, "sk-new");
// Config file value should be used
assert_eq!(merged.endpoint, "https://api.anthropic.com/v1");
}
}

View File

@@ -50,6 +50,8 @@ pub enum AppError {
zh: String,
en: String,
},
#[error("数据库错误: {0}")]
Database(String),
}
impl AppError {
@@ -94,3 +96,28 @@ impl From<AppError> for String {
err.to_string()
}
}
/// 格式化为 JSON 错误字符串,前端可解析为结构化错误
pub fn format_skill_error(
code: &str,
context: &[(&str, &str)],
suggestion: Option<&str>,
) -> String {
use serde_json::json;
let mut ctx_map = serde_json::Map::new();
for (key, value) in context {
ctx_map.insert(key.to_string(), json!(value));
}
let error_obj = json!({
"code": code,
"context": ctx_map,
"suggestion": suggestion,
});
serde_json::to_string(&error_obj).unwrap_or_else(|_| {
// 如果 JSON 序列化失败,返回简单格式
format!("ERROR:{code}")
})
}

View File

@@ -96,7 +96,20 @@ pub fn set_mcp_servers_map(
obj = server_obj;
}
// 移除 UI 辅助字段
// Gemini CLI 格式转换:
// - Gemini 不使用 "type" 字段(从字段名推断传输类型)
// - HTTP 使用 "httpUrl" 字段SSE 使用 "url" 字段
let transport_type = obj.get("type").and_then(|v| v.as_str());
if transport_type == Some("http") {
// HTTP streaming: 将 "url" 重命名为 "httpUrl"
if let Some(url_value) = obj.remove("url") {
obj.insert("httpUrl".to_string(), url_value);
}
}
// SSE 保持 "url" 字段不变
// 移除 UI 辅助字段和 type 字段Gemini 不需要)
obj.remove("type");
obj.remove("enabled");
obj.remove("source");
obj.remove("id");

View File

@@ -13,7 +13,9 @@ fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
INIT_ERROR.get_or_init(|| RwLock::new(None))
}
#[allow(dead_code)]
pub fn set_init_error(payload: InitErrorPayload) {
#[allow(clippy::unwrap_used)]
if let Ok(mut guard) = cell().write() {
*guard = Some(payload);
}

View File

@@ -1,10 +1,12 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
mod commands;
mod config;
mod database;
mod deeplink;
mod error;
mod gemini_config; // 新增
@@ -14,6 +16,7 @@ mod mcp;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod services;
mod settings;
mod store;
@@ -204,8 +207,6 @@ fn create_tray_menu(
let app_settings = crate::settings::get_settings();
let tray_texts = TrayTexts::from_language(app_settings.language.as_deref().unwrap_or("zh"));
let config = app_state.config.read().map_err(AppError::from)?;
let mut menu_builder = MenuBuilder::new(app);
// 顶部:打开主界面
@@ -216,13 +217,20 @@ fn create_tray_menu(
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
for section in TRAY_SECTIONS.iter() {
menu_builder = append_provider_section(
app,
menu_builder,
config.get_manager(&section.app_type),
section,
&tray_texts,
)?;
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
let current_id = app_state
.db
.get_current_provider(app_type_str)?
.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)?;
}
// 分隔符和退出菜单
@@ -521,42 +529,47 @@ pub fn run() {
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
app_store::refresh_app_config_dir_override(app.handle());
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage
// 如果配置解析失败,则向前端发送错误事件并提前结束 setup不落盘、不覆盖配置
let app_state = match AppState::try_new() {
Ok(state) => state,
Err(err) => {
let path = crate::config::get_app_config_path();
let payload_json = serde_json::json!({
"path": path.display().to_string(),
"error": err.to_string(),
});
// 事件通知(可能早于前端订阅,不保证送达)
if let Err(e) = app.emit("configLoadError", payload_json) {
log::error!("发射配置加载错误事件失败: {e}");
}
// 同时缓存错误,供前端启动阶段主动拉取
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
path: path.display().to_string(),
error: err.to_string(),
});
// 不再继续构建托盘/命令依赖的状态,交由前端提示后退出。
return Ok(());
// 初始化数据库
let app_config_dir = crate::config::get_app_config_dir();
let db_path = app_config_dir.join("cc-switch.db");
let json_path = app_config_dir.join("config.json");
// Check if migration is needed (DB doesn't exist but JSON does)
let migration_needed = !db_path.exists() && json_path.exists();
let db = match crate::database::Database::init() {
Ok(db) => Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
// 这里的错误处理比较棘手,因为 setup 返回 Result<Box<dyn Error>>
// 我们暂时记录日志并让应用继续运行(可能会崩溃)或者返回错误
return Err(Box::new(e));
}
};
if migration_needed {
log::info!("Starting migration from config.json to SQLite...");
match crate::app_config::MultiAppConfig::load() {
Ok(config) => {
if let Err(e) = db.migrate_from_json(&config) {
log::error!("Migration failed: {e}");
} else {
log::info!("Migration successful");
// Optional: Rename config.json
// let _ = std::fs::rename(&json_path, json_path.with_extension("json.bak"));
}
}
Err(e) => log::error!("Failed to load config.json for migration: {e}"),
}
}
let app_state = AppState::new(db);
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {e}");
}
// 确保配置结构就绪(已移除旧版本的副本迁移逻辑)
{
let mut config_guard = app_state.config.write().unwrap();
config_guard.ensure_app(&app_config::AppType::Claude);
config_guard.ensure_app(&app_config::AppType::Codex);
}
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API
@@ -704,6 +717,7 @@ pub fn run() {
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,
commands::merge_deeplink_config,
commands::import_from_deeplink,
update_tray_menu,
// Environment variable management
@@ -717,6 +731,9 @@ pub fn run() {
commands::get_skill_repos,
commands::add_skill_repo,
commands::remove_skill_repo,
// Auto launch
commands::set_auto_launch,
commands::get_auto_launch_status,
]);
let app = builder

View File

@@ -449,7 +449,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
// 核心字段(需要手动处理的字段)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "headers"],
"http" | "sse" => vec!["type", "url", "http_headers"],
_ => vec!["type"],
};
@@ -490,7 +490,13 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
spec.insert("url".into(), json!(url));
}
if let Some(headers_tbl) = entry_tbl.get("headers").and_then(|v| v.as_table()) {
// Read from http_headers (correct Codex format) or headers (legacy) with priority to http_headers
let headers_tbl = entry_tbl
.get("http_headers")
.and_then(|v| v.as_table())
.or_else(|| entry_tbl.get("headers").and_then(|v| v.as_table()));
if let Some(headers_tbl) = headers_tbl {
let mut headers_json = serde_json::Map::new();
for (k, v) in headers_tbl.iter() {
if let Some(sv) = v.as_str() {
@@ -910,7 +916,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
// 定义核心字段(已在下方处理,跳过通用转换)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "headers"],
"http" | "sse" => vec!["type", "url", "http_headers"],
_ => vec!["type"],
};
@@ -988,7 +994,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
}
}
if !h_tbl.is_empty() {
t["headers"] = Item::Table(h_tbl);
t["http_headers"] = Item::Table(h_tbl);
}
}
}

View File

@@ -1,3 +1,4 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
@@ -28,6 +29,13 @@ pub struct Provider {
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ProviderMeta>,
/// 图标名称(如 "openai", "anthropic"
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 图标颜色Hex 格式,如 "#00A67E"
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
}
impl Provider {
@@ -48,6 +56,8 @@ impl Provider {
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
}
}
}
@@ -55,7 +65,7 @@ impl Provider {
/// 供应商管理器
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderManager {
pub providers: HashMap<String, Provider>,
pub providers: IndexMap<String, Provider>,
pub current: String,
}
@@ -145,7 +155,7 @@ pub struct ProviderMeta {
impl ProviderManager {
/// 获取所有供应商
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
pub fn get_all_providers(&self) -> &IndexMap<String, Provider> {
&self.providers
}
}

View File

@@ -0,0 +1,238 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
/// 供应商图标信息
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ProviderIcon {
pub name: &'static str,
pub color: &'static str,
}
/// 供应商名称到图标的默认映射
#[allow(dead_code)]
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
let mut m = HashMap::new();
// AI 服务商
m.insert(
"openai",
ProviderIcon {
name: "openai",
color: "#00A67E",
},
);
m.insert(
"anthropic",
ProviderIcon {
name: "anthropic",
color: "#D4915D",
},
);
m.insert(
"claude",
ProviderIcon {
name: "claude",
color: "#D4915D",
},
);
m.insert(
"google",
ProviderIcon {
name: "google",
color: "#4285F4",
},
);
m.insert(
"gemini",
ProviderIcon {
name: "gemini",
color: "#4285F4",
},
);
m.insert(
"deepseek",
ProviderIcon {
name: "deepseek",
color: "#1E88E5",
},
);
m.insert(
"kimi",
ProviderIcon {
name: "kimi",
color: "#6366F1",
},
);
m.insert(
"moonshot",
ProviderIcon {
name: "moonshot",
color: "#6366F1",
},
);
m.insert(
"zhipu",
ProviderIcon {
name: "zhipu",
color: "#0F62FE",
},
);
m.insert(
"minimax",
ProviderIcon {
name: "minimax",
color: "#FF6B6B",
},
);
m.insert(
"baidu",
ProviderIcon {
name: "baidu",
color: "#2932E1",
},
);
m.insert(
"alibaba",
ProviderIcon {
name: "alibaba",
color: "#FF6A00",
},
);
m.insert(
"tencent",
ProviderIcon {
name: "tencent",
color: "#00A4FF",
},
);
m.insert(
"meta",
ProviderIcon {
name: "meta",
color: "#0081FB",
},
);
m.insert(
"microsoft",
ProviderIcon {
name: "microsoft",
color: "#00A4EF",
},
);
m.insert(
"cohere",
ProviderIcon {
name: "cohere",
color: "#39594D",
},
);
m.insert(
"perplexity",
ProviderIcon {
name: "perplexity",
color: "#20808D",
},
);
m.insert(
"mistral",
ProviderIcon {
name: "mistral",
color: "#FF7000",
},
);
m.insert(
"huggingface",
ProviderIcon {
name: "huggingface",
color: "#FFD21E",
},
);
// 云平台
m.insert(
"aws",
ProviderIcon {
name: "aws",
color: "#FF9900",
},
);
m.insert(
"azure",
ProviderIcon {
name: "azure",
color: "#0078D4",
},
);
m.insert(
"huawei",
ProviderIcon {
name: "huawei",
color: "#FF0000",
},
);
m.insert(
"cloudflare",
ProviderIcon {
name: "cloudflare",
color: "#F38020",
},
);
m
});
/// 根据供应商名称智能推断图标
#[allow(dead_code)]
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
let name_lower = provider_name.to_lowercase();
// 精确匹配
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
return Some(icon.clone());
}
// 模糊匹配(包含关键词)
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
if name_lower.contains(key) {
return Some(icon.clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_match() {
let icon = infer_provider_icon("openai");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
assert_eq!(icon.color, "#00A67E");
}
#[test]
fn test_fuzzy_match() {
let icon = infer_provider_icon("OpenAI Official");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
}
#[test]
fn test_case_insensitive() {
let icon = infer_provider_icon("ANTHROPIC");
assert!(icon.is_some());
assert_eq!(icon.unwrap().name, "anthropic");
}
#[test]
fn test_no_match() {
let icon = infer_provider_icon("unknown provider");
assert!(icon.is_none());
}
}

View File

@@ -109,7 +109,17 @@ impl ConfigService {
}
/// 将外部配置文件内容加载并写入应用状态。
pub fn import_config_from_path(file_path: &Path, state: &AppState) -> Result<String, AppError> {
/// TODO: 需要重构以使用数据库而不是 JSON 配置
pub fn import_config_from_path(
_file_path: &Path,
_state: &AppState,
) -> Result<String, AppError> {
// TODO: 实现基于数据库的导入逻辑
Err(AppError::Message(
"配置导入功能正在重构中,暂时不可用".to_string(),
))
/* 旧的实现,需要重构:
let (new_config, backup_id) = Self::load_config_for_import(file_path)?;
{
@@ -118,6 +128,7 @@ impl ConfigService {
}
Ok(backup_id)
*/
}
/// 同步当前供应商到对应的 live 配置。

View File

@@ -1,6 +1,7 @@
use indexmap::IndexMap;
use std::collections::HashMap;
use crate::app_config::{AppType, McpServer, MultiAppConfig};
use crate::app_config::{AppType, McpServer};
use crate::error::AppError;
use crate::mcp;
use crate::store::AppState;
@@ -10,40 +11,13 @@ pub struct McpService;
impl McpService {
/// 获取所有 MCP 服务器(统一结构)
pub fn get_all_servers(state: &AppState) -> Result<HashMap<String, McpServer>, AppError> {
let cfg = state.config.read()?;
// 如果是新结构,直接返回
if let Some(servers) = &cfg.mcp.servers {
return Ok(servers.clone());
}
// 理论上不应该走到这里,因为 load 时会自动迁移
Err(AppError::localized(
"mcp.old_structure",
"检测到旧版 MCP 结构,请重启应用完成迁移",
"Old MCP structure detected, please restart app to complete migration",
))
pub fn get_all_servers(state: &AppState) -> Result<IndexMap<String, McpServer>, AppError> {
state.db.get_all_mcp_servers()
}
/// 添加或更新 MCP 服务器
pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> {
{
let mut cfg = state.config.write()?;
// 确保 servers 字段存在
if cfg.mcp.servers.is_none() {
cfg.mcp.servers = Some(HashMap::new());
}
let servers = cfg.mcp.servers.as_mut().unwrap();
let id = server.id.clone();
// 插入或更新
servers.insert(id, server.clone());
}
state.save()?;
state.db.save_mcp_server(&server)?;
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
@@ -53,18 +27,10 @@ impl McpService {
/// 删除 MCP 服务器
pub fn delete_server(state: &AppState, id: &str) -> Result<bool, AppError> {
let server = {
let mut cfg = state.config.write()?;
if let Some(servers) = &mut cfg.mcp.servers {
servers.remove(id)
} else {
None
}
};
let server = state.db.get_all_mcp_servers()?.shift_remove(id);
if let Some(server) = server {
state.save()?;
state.db.delete_mcp_server(id)?;
// 从所有应用的 live 配置中移除
Self::remove_server_from_all_apps(state, id, &server)?;
@@ -81,27 +47,15 @@ impl McpService {
app: AppType,
enabled: bool,
) -> Result<(), AppError> {
let server = {
let mut cfg = state.config.write()?;
let mut servers = state.db.get_all_mcp_servers()?;
if let Some(servers) = &mut cfg.mcp.servers {
if let Some(server) = servers.get_mut(server_id) {
server.apps.set_enabled_for(&app, enabled);
Some(server.clone())
} else {
None
}
} else {
None
}
};
if let Some(server) = server {
state.save()?;
if let Some(server) = servers.get_mut(server_id) {
server.apps.set_enabled_for(&app, enabled);
state.db.save_mcp_server(server)?;
// 同步到对应应用
if enabled {
Self::sync_server_to_app(state, &server, &app)?;
Self::sync_server_to_app(state, server, &app)?;
} else {
Self::remove_server_from_app(state, server_id, &app)?;
}
@@ -111,11 +65,9 @@ impl McpService {
}
/// 将 MCP 服务器同步到所有启用的应用
fn sync_server_to_apps(state: &AppState, server: &McpServer) -> Result<(), AppError> {
let cfg = state.config.read()?;
fn sync_server_to_apps(_state: &AppState, server: &McpServer) -> Result<(), AppError> {
for app in server.apps.enabled_apps() {
Self::sync_server_to_app_internal(&cfg, server, &app)?;
Self::sync_server_to_app_no_config(server, &app)?;
}
Ok(())
@@ -123,28 +75,24 @@ impl McpService {
/// 将 MCP 服务器同步到指定应用
fn sync_server_to_app(
state: &AppState,
_state: &AppState,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
let cfg = state.config.read()?;
Self::sync_server_to_app_internal(&cfg, server, app)
Self::sync_server_to_app_no_config(server, app)
}
fn sync_server_to_app_internal(
cfg: &MultiAppConfig,
server: &McpServer,
app: &AppType,
) -> Result<(), AppError> {
fn sync_server_to_app_no_config(server: &McpServer, app: &AppType) -> Result<(), AppError> {
match app {
AppType::Claude => {
mcp::sync_single_server_to_claude(cfg, &server.id, &server.server)?;
mcp::sync_single_server_to_claude(&Default::default(), &server.id, &server.server)?;
}
AppType::Codex => {
mcp::sync_single_server_to_codex(cfg, &server.id, &server.server)?;
// Codex uses TOML format, must use the correct function
mcp::sync_single_server_to_codex(&Default::default(), &server.id, &server.server)?;
}
AppType::Gemini => {
mcp::sync_single_server_to_gemini(cfg, &server.id, &server.server)?;
mcp::sync_single_server_to_gemini(&Default::default(), &server.id, &server.server)?;
}
}
Ok(())
@@ -232,29 +180,21 @@ impl McpService {
}
/// 从 Claude 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_claude(&mut cfg)?;
drop(cfg);
state.save()?;
Ok(count)
pub fn import_from_claude(_state: &AppState) -> Result<usize, AppError> {
// TODO: Implement import logic using database
// For now, return 0 as a placeholder
Ok(0)
}
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_codex(&mut cfg)?;
drop(cfg);
state.save()?;
Ok(count)
pub fn import_from_codex(_state: &AppState) -> Result<usize, AppError> {
// TODO: Implement import logic using database
Ok(0)
}
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_gemini(&mut cfg)?;
drop(cfg);
state.save()?;
Ok(count)
pub fn import_from_gemini(_state: &AppState) -> Result<usize, AppError> {
// TODO: Implement import logic using database
Ok(0)
}
}

View File

@@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use crate::app_config::AppType;
use crate::config::write_text_file;
@@ -13,34 +13,20 @@ impl PromptService {
pub fn get_prompts(
state: &AppState,
app: AppType,
) -> Result<HashMap<String, Prompt>, AppError> {
let cfg = state.config.read()?;
let prompts = match app {
AppType::Claude => &cfg.prompts.claude.prompts,
AppType::Codex => &cfg.prompts.codex.prompts,
AppType::Gemini => &cfg.prompts.gemini.prompts,
};
Ok(prompts.clone())
) -> Result<IndexMap<String, Prompt>, AppError> {
state.db.get_prompts(app.as_str())
}
pub fn upsert_prompt(
state: &AppState,
app: AppType,
id: &str,
_id: &str,
prompt: Prompt,
) -> Result<(), AppError> {
// 检查是否为已启用的提示词
let is_enabled = prompt.enabled;
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
prompts.insert(id.to_string(), prompt.clone());
drop(cfg);
state.save()?;
state.db.save_prompt(app.as_str(), &prompt)?;
// 如果是已启用的提示词,同步更新到对应的文件
if is_enabled {
@@ -52,12 +38,7 @@ impl PromptService {
}
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
let prompts = state.db.get_prompts(app.as_str())?;
if let Some(prompt) = prompts.get(id) {
if prompt.enabled {
@@ -65,9 +46,7 @@ impl PromptService {
}
}
prompts.remove(id);
drop(cfg);
state.save()?;
state.db.delete_prompt(app.as_str(), id)?;
Ok(())
}
@@ -77,12 +56,7 @@ impl PromptService {
if target_path.exists() {
if let Ok(live_content) = std::fs::read_to_string(&target_path) {
if !live_content.trim().is_empty() {
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
let mut prompts = state.db.get_prompts(app.as_str())?;
// 尝试回填到当前已启用的提示词
if let Some((enabled_id, enabled_prompt)) = prompts
@@ -97,8 +71,7 @@ impl PromptService {
enabled_prompt.content = live_content.clone();
enabled_prompt.updated_at = Some(timestamp);
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
drop(cfg); // 释放锁后保存,避免死锁
state.save()?; // 第一次保存:回填后立即持久化
state.db.save_prompt(app.as_str(), enabled_prompt)?;
} else {
// 没有已启用的提示词,则创建一次备份(避免重复备份)
let content_exists = prompts
@@ -122,13 +95,8 @@ impl PromptService {
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
prompts.insert(backup_id.clone(), backup_prompt);
log::info!("回填 live 提示词内容,创建备份: {backup_id}");
drop(cfg); // 释放锁后保存
state.save()?; // 第一次保存:回填后立即持久化
} else {
// 即使内容已存在,也无需重复备份;但不需要保存任何更改
drop(cfg);
state.db.save_prompt(app.as_str(), &backup_prompt)?;
}
}
}
@@ -136,12 +104,7 @@ impl PromptService {
}
// 启用目标提示词并写入文件
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
};
let mut prompts = state.db.get_prompts(app.as_str())?;
for prompt in prompts.values_mut() {
prompt.enabled = false;
@@ -150,12 +113,16 @@ impl PromptService {
if let Some(prompt) = prompts.get_mut(id) {
prompt.enabled = true;
write_text_file(&target_path, &prompt.content)?; // 原子写入
state.db.save_prompt(app.as_str(), prompt)?;
} else {
return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
}
drop(cfg);
state.save()?; // 第二次保存:启用目标提示词并写入文件后
// Save all prompts to disable others
for (_, prompt) in prompts.iter() {
state.db.save_prompt(app.as_str(), prompt)?;
}
Ok(())
}

View File

@@ -1,17 +1,17 @@
use indexmap::IndexMap;
use regex::Regex;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app_config::{AppType, MultiAppConfig};
use crate::app_config::AppType;
use crate::codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
use crate::config::{
delete_file, get_claude_settings_path, get_provider_config_path, read_json_file,
write_json_file, write_text_file,
delete_file, get_claude_settings_path, read_json_file, write_json_file, write_text_file,
};
use crate::error::AppError;
use crate::provider::{Provider, ProviderMeta, UsageData, UsageResult};
use crate::provider::{Provider, UsageData, UsageResult};
use crate::settings::{self, CustomEndpoint};
use crate::store::AppState;
use crate::usage_script;
@@ -20,6 +20,7 @@ use crate::usage_script;
pub struct ProviderService;
#[derive(Clone)]
#[allow(dead_code)]
enum LiveSnapshot {
Claude {
settings: Option<Value>,
@@ -35,6 +36,7 @@ enum LiveSnapshot {
}
#[derive(Clone)]
#[allow(dead_code)]
struct PostCommitAction {
app_type: AppType,
provider: Provider,
@@ -44,6 +46,7 @@ struct PostCommitAction {
}
impl LiveSnapshot {
#[allow(dead_code)]
fn restore(&self) -> Result<(), AppError> {
match self {
LiveSnapshot::Claude { settings } => {
@@ -498,246 +501,69 @@ impl ProviderService {
}
}
}
fn run_transaction<R, F>(state: &AppState, f: F) -> Result<R, AppError>
where
F: FnOnce(&mut MultiAppConfig) -> Result<(R, Option<PostCommitAction>), AppError>,
{
let mut guard = state.config.write().map_err(AppError::from)?;
let original = guard.clone();
let (result, action) = match f(&mut guard) {
Ok(value) => value,
Err(err) => {
*guard = original;
return Err(err);
}
};
drop(guard);
if let Err(save_err) = state.save() {
if let Err(rollback_err) = Self::restore_config_only(state, original.clone()) {
return Err(AppError::localized(
"config.save.rollback_failed",
format!("保存配置失败: {save_err};回滚失败: {rollback_err}"),
format!("Failed to save config: {save_err}; rollback failed: {rollback_err}"),
));
}
return Err(save_err);
}
if let Some(action) = action {
if let Err(err) = Self::apply_post_commit(state, &action) {
if let Err(rollback_err) =
Self::rollback_after_failure(state, original.clone(), action.backup.clone())
{
return Err(AppError::localized(
"post_commit.rollback_failed",
format!("后置操作失败: {err};回滚失败: {rollback_err}"),
format!("Post-commit step failed: {err}; rollback failed: {rollback_err}"),
));
}
return Err(err);
}
}
Ok(result)
}
fn restore_config_only(state: &AppState, snapshot: MultiAppConfig) -> Result<(), AppError> {
{
let mut guard = state.config.write().map_err(AppError::from)?;
*guard = snapshot;
}
state.save()
}
fn rollback_after_failure(
state: &AppState,
snapshot: MultiAppConfig,
backup: LiveSnapshot,
) -> Result<(), AppError> {
Self::restore_config_only(state, snapshot)?;
backup.restore()
}
fn apply_post_commit(state: &AppState, action: &PostCommitAction) -> Result<(), AppError> {
Self::write_live_snapshot(&action.app_type, &action.provider)?;
if action.sync_mcp {
// 使用 v3.7.0 统一的 MCP 同步机制,支持所有应用
use crate::services::mcp::McpService;
McpService::sync_all_enabled(state)?;
}
if action.refresh_snapshot {
Self::refresh_provider_snapshot(state, &action.app_type, &action.provider.id)?;
}
Ok(())
}
fn refresh_provider_snapshot(
state: &AppState,
app_type: &AppType,
provider_id: &str,
) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err(AppError::localized(
"claude.live.missing",
"Claude 设置文件不存在,无法刷新快照",
"Claude settings file missing; cannot refresh snapshot",
));
}
let mut live_after = read_json_file::<Value>(&settings_path)?;
let _ = Self::normalize_claude_models_in_value(&mut live_after);
{
let mut guard = state.config.write().map_err(AppError::from)?;
if let Some(manager) = guard.get_manager_mut(app_type) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
}
state.save()?;
}
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.live.missing",
"Codex auth.json 不存在,无法刷新快照",
"Codex auth.json missing; cannot refresh snapshot",
));
}
let auth: Value = read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
{
let mut guard = state.config.write().map_err(AppError::from)?;
if let Some(manager) = guard.get_manager_mut(app_type) {
if let Some(target) = manager.providers.get_mut(provider_id) {
let obj = target.settings_config.as_object_mut().ok_or_else(|| {
AppError::Config(format!(
"供应商 {provider_id} 的 Codex 配置必须是 JSON 对象"
))
})?;
obj.insert("auth".to_string(), auth.clone());
obj.insert("config".to_string(), Value::String(cfg_text.clone()));
}
}
}
state.save()?;
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.live.missing",
"Gemini .env 文件不存在,无法刷新快照",
"Gemini .env file missing; cannot refresh snapshot",
));
}
let env_map = read_gemini_env()?;
let mut live_after = env_to_json(&env_map);
let settings_path = get_gemini_settings_path();
let config_value = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
if let Some(obj) = live_after.as_object_mut() {
obj.insert("config".to_string(), config_value);
}
{
let mut guard = state.config.write().map_err(AppError::from)?;
if let Some(manager) = guard.get_manager_mut(app_type) {
if let Some(target) = manager.providers.get_mut(provider_id) {
target.settings_config = live_after;
}
}
}
state.save()?;
}
}
Ok(())
}
fn capture_live_snapshot(app_type: &AppType) -> Result<LiveSnapshot, AppError> {
fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
let path = get_claude_settings_path();
let settings = if path.exists() {
Some(read_json_file::<Value>(&path)?)
} else {
None
};
Ok(LiveSnapshot::Claude { settings })
write_json_file(&path, &provider.settings_config)?;
}
AppType::Codex => {
let obj = provider.settings_config.as_object().ok_or_else(|| {
AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string())
})?;
let auth = obj.get("auth").ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string())
})?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path();
let auth = if auth_path.exists() {
Some(read_json_file::<Value>(&auth_path)?)
} else {
None
};
let config = if config_path.exists() {
Some(
std::fs::read_to_string(&config_path)
.map_err(|e| AppError::io(&config_path, e))?,
)
} else {
None
};
Ok(LiveSnapshot::Codex { auth, config })
std::fs::write(&config_path, config_str)
.map_err(|e| AppError::io(&config_path, e))?;
}
AppType::Gemini => {
// 新增
use crate::gemini_config::{
get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
get_gemini_settings_path, json_to_env, write_gemini_env_atomic,
};
let path = get_gemini_env_path();
let env = if path.exists() {
Some(read_gemini_env()?)
} else {
None
};
let settings_path = get_gemini_settings_path();
let config = if settings_path.exists() {
Some(read_json_file(&settings_path)?)
} else {
None
};
Ok(LiveSnapshot::Gemini { env, config })
// Extract env and config from provider settings
let env_value = provider.settings_config.get("env");
let config_value = provider.settings_config.get("config");
// Write env file
if let Some(env) = env_value {
let env_map = json_to_env(env)?;
write_gemini_env_atomic(&env_map)?;
}
// Write settings file
if let Some(config) = config_value {
let settings_path = get_gemini_settings_path();
write_json_file(&settings_path, config)?;
}
}
}
Ok(())
}
/// 列出指定应用下的所有供应商
pub fn list(
state: &AppState,
app_type: AppType,
) -> Result<HashMap<String, Provider>, AppError> {
let config = state.config.read().map_err(AppError::from)?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
Ok(manager.get_all_providers().clone())
) -> Result<IndexMap<String, Provider>, AppError> {
state.db.get_all_providers(app_type.as_str())
}
/// 获取当前供应商 ID
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
let config = state.config.read().map_err(AppError::from)?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
Ok(manager.current.clone())
state
.db
.get_current_provider(app_type.as_str())
.map(|opt| opt.unwrap_or_default())
}
/// 新增供应商
@@ -747,35 +573,20 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
let app_type_clone = app_type.clone();
let provider_clone = provider.clone();
// 保存到数据库
state.db.save_provider(app_type.as_str(), &provider)?;
Self::run_transaction(state, move |config| {
config.ensure_app(&app_type_clone);
let manager = config
.get_manager_mut(&app_type_clone)
.ok_or_else(|| Self::app_not_found(&app_type_clone))?;
// 检查是否需要同步(如果是当前供应商,或者没有当前供应商)
let current = state.db.get_current_provider(app_type.as_str())?;
if current.is_none() {
// 如果没有当前供应商,设为当前并同步
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
Self::write_live_snapshot(&app_type, &provider)?;
}
let is_current = manager.current == provider_clone.id;
manager
.providers
.insert(provider_clone.id.clone(), provider_clone.clone());
let action = if is_current {
let backup = Self::capture_live_snapshot(&app_type_clone)?;
Some(PostCommitAction {
app_type: app_type_clone.clone(),
provider: provider_clone.clone(),
backup,
sync_mcp: false,
refresh_snapshot: false,
})
} else {
None
};
Ok((true, action))
})
Ok(true)
}
/// 更新供应商
@@ -788,71 +599,30 @@ impl ProviderService {
// 归一化 Claude 模型键
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
let provider_id = provider.id.clone();
let app_type_clone = app_type.clone();
let provider_clone = provider.clone();
Self::run_transaction(state, move |config| {
let manager = config
.get_manager_mut(&app_type_clone)
.ok_or_else(|| Self::app_not_found(&app_type_clone))?;
// 检查是否为当前供应商
let current_id = state.db.get_current_provider(app_type.as_str())?;
let is_current = current_id.as_deref() == Some(provider.id.as_str());
if !manager.providers.contains_key(&provider_id) {
return Err(AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
));
}
// 保存到数据库
state.db.save_provider(app_type.as_str(), &provider)?;
let is_current = manager.current == provider_id;
let merged = if let Some(existing) = manager.providers.get(&provider_id) {
let mut updated = provider_clone.clone();
match (existing.meta.as_ref(), updated.meta.take()) {
// 前端未提供 meta表示不修改沿用旧值
(Some(old_meta), None) => {
updated.meta = Some(old_meta.clone());
}
(None, None) => {
updated.meta = None;
}
// 前端提供的 meta 视为权威,直接覆盖(其中 custom_endpoints 允许是空,表示删除所有自定义端点)
(_old, Some(new_meta)) => {
updated.meta = Some(new_meta);
}
}
updated
} else {
provider_clone.clone()
};
if is_current {
Self::write_live_snapshot(&app_type, &provider)?;
// Sync MCP
use crate::services::mcp::McpService;
McpService::sync_all_enabled(state)?;
}
manager.providers.insert(provider_id.clone(), merged);
let action = if is_current {
let backup = Self::capture_live_snapshot(&app_type_clone)?;
Some(PostCommitAction {
app_type: app_type_clone.clone(),
provider: provider_clone.clone(),
backup,
sync_mcp: false,
refresh_snapshot: false,
})
} else {
None
};
Ok((true, action))
})
Ok(true)
}
/// 导入当前 live 配置为默认供应商
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
{
let config = state.config.read().map_err(AppError::from)?;
if let Some(manager) = config.get_manager(&app_type) {
if !manager.get_all_providers().is_empty() {
return Ok(());
}
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
return Ok(());
}
}
@@ -926,18 +696,11 @@ impl ProviderService {
);
provider.category = Some("custom".to_string());
{
let mut config = state.config.write().map_err(AppError::from)?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
manager
.providers
.insert(provider.id.clone(), provider.clone());
manager.current = provider.id.clone();
}
state.db.save_provider(app_type.as_str(), &provider)?;
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
state.save()?;
Ok(())
}
@@ -1010,12 +773,8 @@ impl ProviderService {
app_type: AppType,
provider_id: &str,
) -> Result<Vec<CustomEndpoint>, AppError> {
let cfg = state.config.read().map_err(AppError::from)?;
let manager = cfg
.get_manager(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
let Some(provider) = manager.providers.get(provider_id) else {
let providers = state.db.get_all_providers(app_type.as_str())?;
let Some(provider) = providers.get(provider_id) else {
return Ok(vec![]);
};
let Some(meta) = provider.meta.as_ref() else {
@@ -1046,29 +805,9 @@ impl ProviderService {
));
}
{
let mut cfg = state.config.write().map_err(AppError::from)?;
let manager = cfg
.get_manager_mut(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
let provider = manager.providers.get_mut(provider_id).ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
let endpoint = CustomEndpoint {
url: normalized.clone(),
added_at: Self::now_millis(),
last_used: None,
};
meta.custom_endpoints.insert(normalized, endpoint);
}
state.save()?;
state
.db
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
Ok(())
}
@@ -1080,19 +819,9 @@ impl ProviderService {
url: String,
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
{
let mut cfg = state.config.write().map_err(AppError::from)?;
if let Some(manager) = cfg.get_manager_mut(&app_type) {
if let Some(provider) = manager.providers.get_mut(provider_id) {
if let Some(meta) = provider.meta.as_mut() {
meta.custom_endpoints.remove(&normalized);
}
}
}
}
state.save()?;
state
.db
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
Ok(())
}
@@ -1105,20 +834,16 @@ impl ProviderService {
) -> Result<(), AppError> {
let normalized = url.trim().trim_end_matches('/').to_string();
{
let mut cfg = state.config.write().map_err(AppError::from)?;
if let Some(manager) = cfg.get_manager_mut(&app_type) {
if let Some(provider) = manager.providers.get_mut(provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
endpoint.last_used = Some(Self::now_millis());
}
}
// Get provider, update last_used, save back
let mut providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get_mut(provider_id) {
if let Some(meta) = provider.meta.as_mut() {
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
endpoint.last_used = Some(Self::now_millis());
state.db.save_provider(app_type.as_str(), provider)?;
}
}
}
state.save()?;
Ok(())
}
@@ -1128,20 +853,15 @@ impl ProviderService {
app_type: AppType,
updates: Vec<ProviderSortUpdate>,
) -> Result<bool, AppError> {
{
let mut cfg = state.config.write().map_err(AppError::from)?;
let manager = cfg
.get_manager_mut(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
let mut providers = state.db.get_all_providers(app_type.as_str())?;
for update in updates {
if let Some(provider) = manager.providers.get_mut(&update.id) {
provider.sort_index = Some(update.sort_index);
}
for update in updates {
if let Some(provider) = providers.get_mut(&update.id) {
provider.sort_index = Some(update.sort_index);
state.db.save_provider(app_type.as_str(), provider)?;
}
}
state.save()?;
Ok(true)
}
@@ -1222,11 +942,8 @@ impl ProviderService {
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
let config = state.config.read().map_err(AppError::from)?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
let provider = manager.providers.get(provider_id).cloned().ok_or_else(|| {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
@@ -1300,98 +1017,7 @@ impl ProviderService {
.await
}
/// 切换指定应用的供应商
pub fn switch(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> {
let app_type_clone = app_type.clone();
let provider_id_owned = provider_id.to_string();
Self::run_transaction(state, move |config| {
let backup = Self::capture_live_snapshot(&app_type_clone)?;
let provider = match app_type_clone {
AppType::Codex => Self::prepare_switch_codex(config, &provider_id_owned)?,
AppType::Claude => Self::prepare_switch_claude(config, &provider_id_owned)?,
AppType::Gemini => Self::prepare_switch_gemini(config, &provider_id_owned)?,
};
let action = PostCommitAction {
app_type: app_type_clone.clone(),
provider,
backup,
sync_mcp: true, // v3.7.0: 所有应用切换时都同步 MCP防止配置丢失
refresh_snapshot: true,
};
Ok(((), Some(action)))
})
}
fn prepare_switch_codex(
config: &mut MultiAppConfig,
provider_id: &str,
) -> Result<Provider, AppError> {
let provider = config
.get_manager(&AppType::Codex)
.ok_or_else(|| Self::app_not_found(&AppType::Codex))?
.providers
.get(provider_id)
.cloned()
.ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
Self::backfill_codex_current(config, provider_id)?;
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
manager.current = provider_id.to_string();
}
Ok(provider)
}
fn backfill_codex_current(
config: &mut MultiAppConfig,
next_provider: &str,
) -> Result<(), AppError> {
let current_id = config
.get_manager(&AppType::Codex)
.map(|m| m.current.clone())
.unwrap_or_default();
if current_id.is_empty() || current_id == next_provider {
return Ok(());
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Ok(());
}
let auth: Value = read_json_file(&auth_path)?;
let config_path = get_codex_config_path();
let config_text = if config_path.exists() {
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?
} else {
String::new()
};
let live = json!({
"auth": auth,
"config": config_text,
});
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
if let Some(current) = manager.providers.get_mut(&current_id) {
current.settings_config = live;
}
}
Ok(())
}
#[allow(dead_code)]
fn write_codex_live(provider: &Provider) -> Result<(), AppError> {
let settings = provider
.settings_config
@@ -1412,131 +1038,7 @@ impl ProviderService {
Ok(())
}
fn prepare_switch_claude(
config: &mut MultiAppConfig,
provider_id: &str,
) -> Result<Provider, AppError> {
let provider = config
.get_manager(&AppType::Claude)
.ok_or_else(|| Self::app_not_found(&AppType::Claude))?
.providers
.get(provider_id)
.cloned()
.ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
Self::backfill_claude_current(config, provider_id)?;
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
manager.current = provider_id.to_string();
}
Ok(provider)
}
fn prepare_switch_gemini(
config: &mut MultiAppConfig,
provider_id: &str,
) -> Result<Provider, AppError> {
let provider = config
.get_manager(&AppType::Gemini)
.ok_or_else(|| Self::app_not_found(&AppType::Gemini))?
.providers
.get(provider_id)
.cloned()
.ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
Self::backfill_gemini_current(config, provider_id)?;
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
manager.current = provider_id.to_string();
}
Ok(provider)
}
fn backfill_claude_current(
config: &mut MultiAppConfig,
next_provider: &str,
) -> Result<(), AppError> {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Ok(());
}
let current_id = config
.get_manager(&AppType::Claude)
.map(|m| m.current.clone())
.unwrap_or_default();
if current_id.is_empty() || current_id == next_provider {
return Ok(());
}
let mut live = read_json_file::<Value>(&settings_path)?;
let _ = Self::normalize_claude_models_in_value(&mut live);
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
if let Some(current) = manager.providers.get_mut(&current_id) {
current.settings_config = live;
}
}
Ok(())
}
fn backfill_gemini_current(
config: &mut MultiAppConfig,
next_provider: &str,
) -> Result<(), AppError> {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Ok(());
}
let current_id = config
.get_manager(&AppType::Gemini)
.map(|m| m.current.clone())
.unwrap_or_default();
if current_id.is_empty() || current_id == next_provider {
return Ok(());
}
let env_map = read_gemini_env()?;
let mut live = env_to_json(&env_map);
let settings_path = get_gemini_settings_path();
let config_value = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
if let Some(obj) = live.as_object_mut() {
obj.insert("config".to_string(), config_value);
}
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
if let Some(current) = manager.providers.get_mut(&current_id) {
current.settings_config = live;
}
}
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();
@@ -1613,14 +1115,6 @@ impl ProviderService {
Ok(())
}
fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Codex => Self::write_codex_live(provider),
AppType::Claude => Self::write_claude_live(provider),
AppType::Gemini => Self::write_gemini_live(provider), // 新增
}
}
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
@@ -1838,6 +1332,7 @@ impl ProviderService {
}
}
#[allow(dead_code)]
fn app_not_found(app_type: &AppType) -> AppError {
AppError::localized(
"provider.app_not_found",
@@ -1846,76 +1341,44 @@ impl ProviderService {
)
}
/// 删除供应商
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
let current = state.db.get_current_provider(app_type.as_str())?;
if current.as_deref() == Some(id) {
return Err(AppError::Message(
"无法删除当前正在使用的供应商".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)
}
/// 切换供应商
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Set current
state.db.set_current_provider(app_type.as_str(), id)?;
// Sync to live
Self::write_live_snapshot(&app_type, provider)?;
// Sync MCP
use crate::services::mcp::McpService;
McpService::sync_all_enabled(state)?;
Ok(())
}
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
pub fn delete(state: &AppState, app_type: AppType, provider_id: &str) -> Result<(), AppError> {
let provider_snapshot = {
let config = state.config.read().map_err(AppError::from)?;
let manager = config
.get_manager(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
if manager.current == provider_id {
return Err(AppError::localized(
"provider.delete.current",
"不能删除当前正在使用的供应商",
"Cannot delete the provider currently in use",
));
}
manager.providers.get(provider_id).cloned().ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?
};
match app_type {
AppType::Codex => {
crate::codex_config::delete_codex_provider_config(
provider_id,
&provider_snapshot.name,
)?;
}
AppType::Claude => {
// 兼容旧版本:历史上会在 Claude 目录内为每个供应商生成 settings-*.json 副本
// 这里继续清理这些遗留文件,避免堆积过期配置。
let by_name = get_provider_config_path(provider_id, Some(&provider_snapshot.name));
let by_id = get_provider_config_path(provider_id, None);
delete_file(&by_name)?;
delete_file(&by_id)?;
}
AppType::Gemini => {
// Gemini 使用单一的 .env 文件,不需要删除单独的供应商配置文件
}
}
{
let mut config = state.config.write().map_err(AppError::from)?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| Self::app_not_found(&app_type))?;
if manager.current == provider_id {
return Err(AppError::localized(
"provider.delete.current",
"不能删除当前正在使用的供应商",
"Cannot delete the provider currently in use",
));
}
manager.providers.remove(provider_id);
}
state.save()
}
}
#[derive(Debug, Clone, Deserialize)]

View File

@@ -7,6 +7,8 @@ use std::fs;
use std::path::{Path, PathBuf};
use tokio::time::timeout;
use crate::error::format_skill_error;
/// 技能对象
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
@@ -133,7 +135,11 @@ impl SkillService {
}
fn get_install_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("无法获取用户主目录")?;
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
Ok(home.join(".claude").join("skills"))
}
}
@@ -173,9 +179,19 @@ impl SkillService {
/// 从仓库获取技能列表
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
// 为单个仓库加载增加整体超时,避免无效链接长时间阻塞
let temp_dir = timeout(std::time::Duration::from_secs(15), self.download_repo(repo))
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
.await
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
let mut skills = Vec::new();
// 确定要扫描的目录路径
@@ -379,7 +395,17 @@ impl SkillService {
// 下载 ZIP
let response = self.http_client.get(url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("下载失败: {}", response.status()));
let status = response.status().as_u16().to_string();
return Err(anyhow::anyhow!(format_skill_error(
"DOWNLOAD_FAILED",
&[("status", &status)],
match status.as_str() {
"403" => Some("http403"),
"404" => Some("http404"),
"429" => Some("http429"),
_ => Some("checkNetwork"),
},
)));
}
let bytes = response.bytes().await?;
@@ -394,7 +420,11 @@ impl SkillService {
let name = first_file.name();
name.split('/').next().unwrap_or("").to_string()
} else {
return Err(anyhow::anyhow!("空的压缩包"));
return Err(anyhow::anyhow!(format_skill_error(
"EMPTY_ARCHIVE",
&[],
Some("checkRepoUrl"),
)));
};
// 解压所有文件
@@ -441,16 +471,28 @@ impl SkillService {
// 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程
let temp_dir = timeout(
std::time::Duration::from_secs(15),
std::time::Duration::from_secs(60),
self.download_repo(&repo),
)
.await
.map_err(|_| anyhow!("下载仓库 {}/{} 超时", repo.owner, repo.name))??;
.map_err(|_| {
anyhow!(format_skill_error(
"DOWNLOAD_TIMEOUT",
&[
("owner", &repo.owner),
("name", &repo.name),
("timeout", "60")
],
Some("checkNetwork"),
))
})??;
// 根据 skills_path 确定源目录路径
let source = if let Some(ref skills_path) = repo.skills_path {
// 如果指定了 skills_path源路径为: temp_dir/skills_path/directory
temp_dir.join(skills_path.trim_matches('/')).join(&directory)
temp_dir
.join(skills_path.trim_matches('/'))
.join(&directory)
} else {
// 否则源路径为: temp_dir/directory
temp_dir.join(&directory)
@@ -458,10 +500,11 @@ impl SkillService {
if !source.exists() {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow::anyhow!(
"技能目录不存在: {}",
source.display()
));
return Err(anyhow::anyhow!(format_skill_error(
"SKILL_DIR_NOT_FOUND",
&[("path", &source.display().to_string())],
Some("checkRepoUrl"),
)));
}
// 删除旧版本

View File

@@ -49,6 +49,9 @@ pub struct AppSettings {
pub gemini_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// 是否开机自启
#[serde(default)]
pub launch_on_startup: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub security: Option<SecuritySettings>,
/// Claude 自定义端点列表
@@ -77,6 +80,7 @@ impl Default for AppSettings {
codex_config_dir: None,
gemini_config_dir: None,
language: None,
launch_on_startup: false,
security: None,
custom_endpoints_claude: HashMap::new(),
custom_endpoints_codex: HashMap::new(),

View File

@@ -1,26 +1,14 @@
use crate::app_config::MultiAppConfig;
use crate::error::AppError;
use std::sync::RwLock;
use crate::database::Database;
use std::sync::Arc;
/// 全局应用状态
pub struct AppState {
pub config: RwLock<MultiAppConfig>,
pub db: Arc<Database>,
}
impl AppState {
/// 创建新的应用状态
/// 注意:仅在配置成功加载时返回;不会在失败时回退默认值。
pub fn try_new() -> Result<Self, AppError> {
let config = MultiAppConfig::load()?;
Ok(Self {
config: RwLock::new(config),
})
}
/// 保存配置到文件
pub fn save(&self) -> Result<(), AppError> {
let config = self.config.read().map_err(AppError::from)?;
config.save()
pub fn new(db: Arc<Database>) -> Self {
Self { db }
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.7.0",
"version": "3.7.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
@@ -14,6 +14,7 @@
{
"label": "main",
"title": "",
"titleBarStyle": "Overlay",
"width": 1000,
"height": 650,
"minWidth": 900,

View File

@@ -1,7 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Settings, Edit3 } from "lucide-react";
import {
Plus,
Settings,
ArrowLeft,
Bot,
Book,
Wrench,
Server,
RefreshCw,
} from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery } from "@/lib/query";
@@ -19,7 +28,7 @@ import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import UsageScriptModal from "@/components/UsageScriptModal";
@@ -27,34 +36,34 @@ import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
function App() {
const { t } = useTranslation();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [isEditMode, setIsEditMode] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [currentView, setCurrentView] = useState<View>("providers");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const [isPromptOpen, setIsPromptOpen] = useState(false);
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
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 providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -98,7 +107,10 @@ function App() {
if (flatConflicts.length > 0) {
setEnvConflicts(flatConflicts);
setShowEnvBanner(true);
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
@@ -128,7 +140,10 @@ function App() {
);
return [...prev, ...newConflicts];
});
setShowEnvBanner(true);
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
@@ -229,13 +244,81 @@ function App() {
}
};
const renderContent = () => {
switch (currentView) {
case "settings":
return (
<SettingsPage
open={true}
onOpenChange={() => setCurrentView("providers")}
onImportSuccess={handleImportSuccess}
/>
);
case "prompts":
return (
<PromptPanel
ref={promptPanelRef}
open={true}
onOpenChange={() => setCurrentView("providers")}
appId={activeApp}
/>
);
case "skills":
return (
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
/>
);
case "mcp":
return (
<UnifiedMcpPanel
ref={mcpPanelRef}
onOpenChange={() => setCurrentView("providers")}
/>
);
case "agents":
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>
);
}
};
return (
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
<div
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden" }}
>
{/* 全局拖拽区域(顶部 4px避免上边框无法拖动 */}
<div
className="fixed top-0 left-0 right-0 h-4 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
/>
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
onDismiss={() => setShowEnvBanner(false)}
onDismiss={() => {
setShowEnvBanner(false);
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDeleted={async () => {
// 删除后重新检测
try {
@@ -255,92 +338,182 @@ function App() {
/>
)}
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSettingsOpen(true)}
title={t("common.settings")}
className="ml-2"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditMode(!isEditMode)}
title={t(
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
)}
className={
isEditMode
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
: ""
}
>
<Edit3 className="h-4 w-4" />
</Button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
<header
className="glass-header fixed top-0 z-50 w-full py-3 transition-all duration-300"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
<div
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" } as any}
>
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView !== "providers" ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentView("providers")}
className="mr-2 rounded-lg"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-lg font-semibold">
{currentView === "settings" && t("settings.title")}
{currentView === "prompts" &&
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && "Agents"}
</h1>
</div>
) : (
<>
<div className="flex items-center gap-2">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<div className="h-5 w-[1px] bg-black/10 dark:bg-white/15" />
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentView("settings")}
title={t("common.settings")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4" />
</Button>
</div>
<UpdateBadge onClick={() => setCurrentView("settings")} />
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<Button
variant="mcp"
onClick={() => setIsPromptOpen(true)}
className="min-w-[80px]"
>
{t("prompts.manage")}
</Button>
<Button
variant="mcp"
onClick={() => setIsMcpOpen(true)}
className="min-w-[80px]"
>
MCP
</Button>
<Button
variant="mcp"
onClick={() => setIsSkillsOpen(true)}
className="min-w-[80px]"
>
{t("skills.manage")}
</Button>
<Button onClick={() => setIsAddOpen(true)}>
<Plus className="h-4 w-4" />
{t("header.addProvider")}
</Button>
<div
className="flex items-center gap-2"
style={{ WebkitAppRegion: "no-drag" } as any}
>
{currentView === "prompts" && (
<Button
size="icon"
onClick={() => promptPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("prompts.add")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === "mcp" && (
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === "providers" && (
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("prompts")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="h-4 w-4" />
</Button>
{isClaudeApp && (
<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>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("mcp")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP"
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("agents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)}
</div>
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="h-5 w-5" />
</Button>
</>
)}
</div>
</div>
</header>
<main className="flex-1 overflow-y-scroll">
<div className="mx-auto max-w-4xl px-6 py-6">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isEditMode={isEditMode}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
<main
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
currentView === "providers" ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
</main>
<AddProviderDialog
@@ -388,30 +561,6 @@ function App() {
onCancel={() => setConfirmDelete(null)}
/>
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onImportSuccess={handleImportSuccess}
/>
<PromptPanel
open={isPromptOpen}
onOpenChange={setIsPromptOpen}
appId={activeApp}
/>
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
<DialogHeader className="sr-only">
<VisuallyHidden>
<DialogTitle>{t("skills.title")}</DialogTitle>
</VisuallyHidden>
</DialogHeader>
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
</DialogContent>
</Dialog>
<DeepLinkImportDialog />
</div>
);

View File

@@ -13,13 +13,13 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
return (
<div className="inline-flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-1 border border-transparent ">
<div className="inline-flex bg-gray-100 dark:bg-gray-800 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 dark:shadow-none"
? "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"
}`}
>
@@ -27,8 +27,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
size={16}
className={
activeApp === "claude"
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<span>Claude</span>
@@ -39,11 +39,18 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
onClick={() => handleSwitch("codex")}
className={`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 dark:shadow-none"
? "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"
}`}
>
<CodexIcon size={16} />
<CodexIcon
size={16}
className={
activeApp === "codex"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<span>Codex</span>
</button>
@@ -52,7 +59,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
onClick={() => handleSwitch("gemini")}
className={`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 dark:shadow-none"
? "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"
}`}
>
@@ -60,8 +67,8 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
size={16}
className={
activeApp === "gemini"
? "text-[#4285F4] dark:text-[#4285F4] transition-colors duration-200"
: "text-gray-500 dark:text-gray-400 group-hover:text-[#4285F4] dark:group-hover:text-[#4285F4] transition-colors duration-200"
? "text-foreground"
: "text-gray-500 dark:text-gray-400 group-hover:text-foreground transition-colors"
}
/>
<span>Gemini</span>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,76 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface ColorPickerProps {
value?: string;
onValueChange: (color: string) => void;
label?: string;
presets?: string[];
}
const DEFAULT_PRESETS = [
"#00A67E",
"#D4915D",
"#4285F4",
"#FF6A00",
"#00A4FF",
"#FF9900",
"#0078D4",
"#FF0000",
"#1E88E5",
"#6366F1",
"#0F62FE",
"#2932E1",
];
export const ColorPicker: React.FC<ColorPickerProps> = ({
value = "#4285F4",
onValueChange,
label = "图标颜色",
presets = DEFAULT_PRESETS,
}) => {
return (
<div className="space-y-3">
<Label>{label}</Label>
{/* 颜色预设 */}
<div className="grid grid-cols-6 gap-2">
{presets.map((color) => (
<button
key={color}
type="button"
onClick={() => onValueChange(color)}
className={cn(
"w-full aspect-square rounded-lg border-2 transition-all",
"hover:scale-110 hover:shadow-lg",
value === color
? "border-primary ring-2 ring-primary/20"
: "border-border",
)}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
{/* 自定义颜色输入 */}
<div className="flex items-center gap-2">
<Input
type="color"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder="#4285F4"
className="flex-1 font-mono"
/>
</div>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { listen } from "@tauri-apps/api/event";
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
import {
@@ -30,9 +30,30 @@ export function DeepLinkImportDialog() {
// Listen for deep link import events
const unlistenImport = listen<DeepLinkImportRequest>(
"deeplink-import",
(event) => {
async (event) => {
console.log("Deep link import event received:", event.payload);
setRequest(event.payload);
// If config is present, merge it to get the complete configuration
if (event.payload.config || event.payload.configUrl) {
try {
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
event.payload,
);
console.log("Config merged successfully:", mergedRequest);
setRequest(mergedRequest);
} catch (error) {
console.error("Failed to merge config:", error);
toast.error(t("deeplink.configMergeError"), {
description:
error instanceof Error ? error.message : String(error),
});
// Fall back to original request
setRequest(event.payload);
}
} else {
setRequest(event.payload);
}
setIsOpen(true);
},
);
@@ -71,7 +92,6 @@ export function DeepLinkImportDialog() {
});
setIsOpen(false);
setRequest(null);
} catch (error) {
console.error("Failed to import provider from deep link:", error);
toast.error(t("deeplink.importError"), {
@@ -84,120 +104,326 @@ export function DeepLinkImportDialog() {
const handleCancel = () => {
setIsOpen(false);
setRequest(null);
};
if (!request) return null;
// Mask API key for display (show first 4 chars + ***)
const maskedApiKey =
request.apiKey.length > 4
request?.apiKey && request.apiKey.length > 4
? `${request.apiKey.substring(0, 4)}${"*".repeat(20)}`
: "****";
// Check if config file is present
const hasConfigFile = !!(request?.config || request?.configUrl);
const configSource = request?.config
? "base64"
: request?.configUrl
? "url"
: null;
// Parse config file content for display
interface ParsedConfig {
type: "claude" | "codex" | "gemini";
env?: Record<string, string>;
auth?: Record<string, string>;
tomlConfig?: string;
raw: Record<string, unknown>;
}
// Helper to decode base64 with UTF-8 support
const b64ToUtf8 = (str: string): string => {
try {
const binString = atob(str);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
return new TextDecoder().decode(bytes);
} catch (e) {
console.error("Failed to decode base64:", e);
return atob(str);
}
};
const parsedConfig = useMemo((): ParsedConfig | null => {
if (!request?.config) return null;
try {
const decoded = b64ToUtf8(request.config);
const parsed = JSON.parse(decoded) as Record<string, unknown>;
if (request.app === "claude") {
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
raw: parsed,
};
} else if (request.app === "codex") {
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: (parsed.config as string) || "",
raw: parsed,
};
} else if (request.app === "gemini") {
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
return {
type: "gemini",
env: parsed as Record<string, string>,
raw: parsed,
};
}
return null;
} catch (e) {
console.error("Failed to parse config:", e);
return null;
}
}, [request?.config, request?.app]);
// Helper to mask sensitive values
const maskValue = (key: string, value: string): string => {
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
const isSensitive = sensitiveKeys.some((k) =>
key.toUpperCase().includes(k),
);
if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`;
}
return value;
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[500px]">
{/* 标题显式左对齐,避免默认居中样式影响 */}
<DialogHeader className="text-left sm:text-left">
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
<DialogDescription>
{t("deeplink.confirmImportDescription")}
</DialogDescription>
</DialogHeader>
<Dialog open={isOpen && !!request} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[500px]" zIndex="top">
{request && (
<>
{/* 标题显式左对齐,避免默认居中样式影响 */}
<DialogHeader className="text-left sm:text-left">
<DialogTitle>{t("deeplink.confirmImport")}</DialogTitle>
<DialogDescription>
{t("deeplink.confirmImportDescription")}
</DialogDescription>
</DialogHeader>
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
<div className="space-y-4 px-8 py-4">
{/* App Type */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.app")}
</div>
<div className="col-span-2 text-sm font-medium capitalize">
{request.app}
</div>
</div>
{/* Provider Name */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.providerName")}
</div>
<div className="col-span-2 text-sm font-medium">{request.name}</div>
</div>
{/* Homepage */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.homepage")}
</div>
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
{request.homepage}
</div>
</div>
{/* API Endpoint */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all">
{request.endpoint}
</div>
</div>
{/* API Key (masked) */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.apiKey")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{maskedApiKey}
</div>
</div>
{/* Model (if present) */}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.model")}
{/* 主体内容整体右移,略大于标题内边距,让内容看起来不贴边 */}
<div className="space-y-4 px-8 py-4 max-h-[60vh] overflow-y-auto [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar]:block [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-200 dark:[&::-webkit-scrollbar-thumb]:bg-gray-700">
{/* App Type */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.app")}
</div>
<div className="col-span-2 text-sm font-medium capitalize">
{request.app}
</div>
</div>
<div className="col-span-2 text-sm font-mono">
{request.model}
{/* Provider Name */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.providerName")}
</div>
<div className="col-span-2 text-sm font-medium">
{request.name}
</div>
</div>
{/* Homepage */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.homepage")}
</div>
<div className="col-span-2 text-sm break-all text-blue-600 dark:text-blue-400">
{request.homepage}
</div>
</div>
{/* API Endpoint */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all">
{request.endpoint}
</div>
</div>
{/* API Key (masked) */}
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.apiKey")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground">
{maskedApiKey}
</div>
</div>
{/* Model (if present) */}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.model")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.model}
</div>
</div>
)}
{/* Notes (if present) */}
{request.notes && (
<div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.notes")}
</div>
<div className="col-span-2 text-sm text-muted-foreground">
{request.notes}
</div>
</div>
)}
{/* Config File Details (v3.8+) */}
{hasConfigFile && (
<div className="space-y-3 pt-2 border-t border-border-default">
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.configSource")}
</div>
<div className="col-span-2 text-sm">
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
{configSource === "base64"
? t("deeplink.configEmbedded")
: t("deeplink.configRemote")}
</span>
{request.configFormat && (
<span className="ml-2 text-xs text-muted-foreground uppercase">
{request.configFormat}
</span>
)}
</div>
</div>
{/* Parsed Config Details */}
{parsedConfig && (
<div className="rounded-lg bg-muted/50 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{t("deeplink.configDetails")}
</div>
{/* Claude config */}
{parsedConfig.type === "claude" && parsedConfig.env && (
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
{/* Codex config */}
{parsedConfig.type === "codex" && (
<div className="space-y-2">
{parsedConfig.auth &&
Object.keys(parsedConfig.auth).length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
Auth:
</div>
{Object.entries(parsedConfig.auth).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs pl-2"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
{parsedConfig.tomlConfig && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
TOML Config:
</div>
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
{parsedConfig.tomlConfig.substring(0, 300)}
{parsedConfig.tomlConfig.length > 300 && "..."}
</pre>
</div>
)}
</div>
)}
{/* Gemini config */}
{parsedConfig.type === "gemini" && parsedConfig.env && (
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
</div>
)}
</div>
)}
{/* Config URL (if remote) */}
{request.configUrl && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.configUrl")}
</div>
<div className="col-span-2 text-sm font-mono text-muted-foreground break-all">
{request.configUrl}
</div>
</div>
)}
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")}
</div>
</div>
)}
{/* Notes (if present) */}
{request.notes && (
<div className="grid grid-cols-3 items-start gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.notes")}
</div>
<div className="col-span-2 text-sm text-muted-foreground">
{request.notes}
</div>
</div>
)}
{/* Warning */}
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
{t("deeplink.warning")}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isImporting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
</Button>
</DialogFooter>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isImporting}
>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? t("deeplink.importing") : t("deeplink.import")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,85 @@
import React, { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProviderIcon } from "./ProviderIcon";
import { iconList } from "@/icons/extracted";
import { searchIcons, getIconMetadata } from "@/icons/extracted/metadata";
import { cn } from "@/lib/utils";
interface IconPickerProps {
value?: string; // 当前选中的图标
onValueChange: (icon: string) => void; // 选择回调
color?: string; // 预览颜色
}
export const IconPicker: React.FC<IconPickerProps> = ({
value,
onValueChange,
}) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
// 过滤图标列表
const filteredIcons = useMemo(() => {
if (!searchQuery) return iconList;
return searchIcons(searchQuery);
}, [searchQuery]);
return (
<div className="space-y-4">
<div>
<Label htmlFor="icon-search">
{t("iconPicker.search", { defaultValue: "搜索图标" })}
</Label>
<Input
id="icon-search"
type="text"
placeholder={t("iconPicker.searchPlaceholder", {
defaultValue: "输入图标名称...",
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mt-2"
/>
</div>
<div className="max-h-[65vh] overflow-y-auto pr-1">
<div className="grid grid-cols-6 sm:grid-cols-8 lg:grid-cols-10 gap-2">
{filteredIcons.map((iconName) => {
const meta = getIconMetadata(iconName);
const isSelected = value === iconName;
return (
<button
key={iconName}
type="button"
onClick={() => onValueChange(iconName)}
className={cn(
"flex flex-col items-center gap-1 p-3 rounded-lg",
"border-2 transition-all duration-200",
"hover:bg-accent hover:border-primary/50",
isSelected
? "border-primary bg-primary/10"
: "border-transparent",
)}
title={meta?.displayName || iconName}
>
<ProviderIcon icon={iconName} name={iconName} size={32} />
<span className="text-xs text-muted-foreground truncate w-full text-center">
{meta?.displayName || iconName}
</span>
</button>
);
})}
</div>
</div>
{filteredIcons.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
{t("iconPicker.noResults", { defaultValue: "未找到匹配的图标" })}
</div>
)}
</div>
);
};

View File

@@ -12,6 +12,7 @@ import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface JsonEditorProps {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
@@ -19,7 +20,8 @@ interface JsonEditorProps {
rows?: number;
showValidation?: boolean;
language?: "json" | "javascript";
height?: string;
height?: string | number;
showMinimap?: boolean; // 添加此属性以防未来使用
}
const JsonEditor: React.FC<JsonEditorProps> = ({
@@ -84,19 +86,47 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
// 使用 baseTheme 定义基础样式,优先级低于 oneDark但可以正确响应主题
const baseTheme = EditorView.baseTheme({
"&light .cm-editor, &dark .cm-editor": {
".cm-editor": {
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
background: "transparent",
},
"&light .cm-editor.cm-focused, &dark .cm-editor.cm-focused": {
".cm-editor.cm-focused": {
outline: "none",
borderColor: "hsl(var(--primary))",
},
".cm-scroller": {
background: "transparent",
},
".cm-gutters": {
background: "transparent",
borderRight: "1px solid hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
},
".cm-selectionBackground, .cm-content ::selection": {
background: "hsl(var(--primary) / 0.18)",
},
".cm-selectionMatch": {
background: "hsl(var(--primary) / 0.12)",
},
".cm-activeLine": {
background: "hsl(var(--primary) / 0.08)",
},
".cm-activeLineGutter": {
background: "hsl(var(--primary) / 0.08)",
},
});
// 使用 theme 定义尺寸和字体样式
const heightValue = height
? typeof height === "number"
? `${height}px`
: height
: undefined;
const sizingTheme = EditorView.theme({
"&": height ? { height } : { minHeight: `${minHeightPx}px` },
"&": heightValue
? { height: heightValue }
: { minHeight: `${minHeightPx}px` },
".cm-scroller": { overflow: "auto" },
".cm-content": {
fontFamily:
@@ -129,11 +159,32 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
".cm-editor": {
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
background: "transparent",
},
".cm-editor.cm-focused": {
outline: "none",
borderColor: "hsl(var(--primary))",
},
".cm-scroller": {
background: "transparent",
},
".cm-gutters": {
background: "transparent",
borderRight: "1px solid hsl(var(--border))",
color: "hsl(var(--muted-foreground))",
},
".cm-selectionBackground, .cm-content ::selection": {
background: "hsl(var(--primary) / 0.18)",
},
".cm-selectionMatch": {
background: "hsl(var(--primary) / 0.12)",
},
".cm-activeLine": {
background: "hsl(var(--primary) / 0.08)",
},
".cm-activeLineGutter": {
background: "hsl(var(--primary) / 0.08)",
},
}),
);
}
@@ -196,14 +247,23 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
}
};
const isFullHeight = height === "100%";
return (
<div style={{ width: "100%" }}>
<div ref={editorRef} style={{ width: "100%" }} />
<div
style={{ width: "100%", height: isFullHeight ? "100%" : "auto" }}
className={isFullHeight ? "flex flex-col" : ""}
>
<div
ref={editorRef}
style={{ width: "100%", height: isFullHeight ? undefined : "auto" }}
className={isFullHeight ? "flex-1 min-h-0" : ""}
/>
{language === "json" && (
<button
type="button"
onClick={handleFormat}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
className={`${isFullHeight ? "mt-2 flex-shrink-0" : "mt-2"} inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors`}
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}

View File

@@ -0,0 +1,81 @@
import React, { useMemo } from "react";
import { getIcon, hasIcon } from "@/icons/extracted";
import { cn } from "@/lib/utils";
interface ProviderIconProps {
icon?: string; // 图标名称
name: string; // 供应商名称(用于 fallback
color?: string; // 自定义颜色 (Deprecated, kept for compatibility but ignored for SVG)
size?: number | string; // 尺寸
className?: string;
showFallback?: boolean; // 是否显示 fallback
}
export const ProviderIcon: React.FC<ProviderIconProps> = ({
icon,
name,
size = 32,
className,
showFallback = true,
}) => {
// 获取图标 SVG
const iconSvg = useMemo(() => {
if (icon && hasIcon(icon)) {
return getIcon(icon);
}
return "";
}, [icon]);
// 计算尺寸样式
const sizeStyle = useMemo(() => {
const sizeValue = typeof size === "number" ? `${size}px` : size;
return {
width: sizeValue,
height: sizeValue,
};
}, [size]);
// 如果有图标,显示图标
if (iconSvg) {
return (
<span
className={cn(
"inline-flex items-center justify-center flex-shrink-0",
className,
)}
style={sizeStyle}
dangerouslySetInnerHTML={{ __html: iconSvg }}
/>
);
}
// Fallback显示首字母
if (showFallback) {
const initials = name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<span
className={cn(
"inline-flex items-center justify-center flex-shrink-0 rounded-lg",
"bg-muted text-muted-foreground font-semibold",
className,
)}
style={sizeStyle}
>
<span
style={{
fontSize: `${typeof size === "number" ? size * 0.4 : 14}px`,
}}
>
{initials}
</span>
</span>
);
}
return null;
};

View File

@@ -60,7 +60,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
if (!usage.success) {
if (inline) {
return (
<div className="flex items-center gap-2 text-xs">
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
<div className="flex items-center gap-1.5 text-red-500 dark:text-red-400">
<AlertCircle size={12} />
<span>{t("usage.queryFailed")}</span>
@@ -68,7 +68,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
@@ -78,7 +78,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
}
return (
<div className="mt-3 pt-3 border-t border-border-default ">
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
<AlertCircle size={14} />
@@ -110,29 +110,32 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
const isExpired = firstUsage.isValid === false;
return (
<div className="flex flex-col gap-1 text-xs flex-shrink-0">
{/* 第一行:新时间 + 刷新按钮 */}
<div className="flex flex-col items-end gap-1 text-xs whitespace-nowrap flex-shrink-0">
{/* 第一行:新时间刷新按钮 */}
<div className="flex items-center gap-2 justify-end">
{/* 上次查询时间 */}
{lastQueriedAt && (
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(lastQueriedAt, now, t)}
</span>
)}
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Clock size={10} />
{lastQueriedAt
? formatRelativeTime(lastQueriedAt, now, t)
: t("usage.never", { defaultValue: "从未更新" })}
</span>
{/* 刷新按钮 */}
<button
onClick={() => refetch()}
onClick={(e) => {
e.stopPropagation();
refetch();
}}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0"
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
</button>
</div>
{/* 第二行:已用 + 剩余 + 单位 */}
{/* 第二行:用量和剩余 */}
<div className="flex items-center gap-2">
{/* 已用 */}
{firstUsage.used !== undefined && (
@@ -179,7 +182,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
}
return (
<div className="mt-3 pt-3 border-t border-border-default ">
<div className="mt-3 rounded-xl border border-border-default bg-card px-4 py-3 shadow-sm">
{/* 标题行:包含刷新按钮和自动查询时间 */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
@@ -196,7 +199,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
<button
onClick={() => refetch()}
disabled={loading}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50"
title={t("usage.refreshUsage")}
>
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff } from "lucide-react";
import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { Provider, UsageScript } from "@/types";
@@ -8,17 +8,12 @@ import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
import * as pluginEstree from "prettier/plugins/estree";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { cn } from "@/lib/utils";
interface UsageScriptModalProps {
provider: Provider;
@@ -131,88 +126,53 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [testing, setTesting] = useState(false);
// 🔧 输入时的格式化(宽松)- 只清理格式,不约束范围
const sanitizeNumberInput = (value: string): string => {
// 移除所有非数字字符
let cleaned = value.replace(/[^\d]/g, "");
// 移除前导零(除非输入的就是 "0"
if (cleaned.length > 1 && cleaned.startsWith("0")) {
cleaned = cleaned.replace(/^0+/, "");
}
return cleaned;
};
// 🔧 失焦时的验证(严格)- 仅确保有效整数
const validateTimeout = (value: string): number => {
// 转换为数字
const num = Number(value);
// 检查是否为有效数字
if (isNaN(num) || value.trim() === "") {
return 10; // 默认值
return 10;
}
// 检查是否为整数
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
);
}
// 检查负数
if (num < 0) {
toast.error(
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
);
return 10;
}
return Math.floor(num);
};
// 🔧 失焦时的验证(严格)- 自动查询间隔
const validateAndClampInterval = (value: string): number => {
// 转换为数字
const num = Number(value);
// 检查是否为有效数字
if (isNaN(num) || value.trim() === "") {
return 0; // 禁用自动查询
return 0;
}
// 检查是否为整数
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
);
}
// 检查负数
if (num < 0) {
toast.error(
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
);
return 0;
}
// 约束到 [0, 1440] 范围最大24小时
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
// 如果值被调整,显示提示
if (clamped !== num && num > 0) {
toast.info(
t("usageScript.intervalAdjusted", { value: clamped }) ||
`自动查询间隔已调整为 ${clamped} 分钟`,
);
}
return clamped;
};
// 跟踪当前选择的模板类型(用于控制高级配置的显示)
// 初始化:如果已有 accessToken 或 userId说明是 NewAPI 模板
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
@@ -223,23 +183,18 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
},
);
// 控制 API Key 的显示/隐藏
const [showApiKey, setShowApiKey] = useState(false);
const [showAccessToken, setShowAccessToken] = useState(false);
const handleSave = () => {
// 验证脚本格式
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
return;
}
// 基本的 JS 语法检查(检查是否包含 return 语句)
if (script.enabled && !script.code.includes("return")) {
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
onSave(script);
onClose();
};
@@ -247,7 +202,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleTest = async () => {
setTesting(true);
try {
// 使用当前编辑器中的脚本内容进行测试
const result = await usageApi.testScript(
provider.id,
appId,
@@ -259,7 +213,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
script.userId,
);
if (result.success && result.data && result.data.length > 0) {
// 显示所有套餐数据
const summary = result.data
.map((plan) => {
const planInfo = plan.planName ? `[${plan.planName}]` : "";
@@ -314,9 +267,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName];
if (preset) {
// 根据模板类型清空不同的字段
if (presetName === TEMPLATE_KEYS.CUSTOM) {
// 自定义:清空所有凭证字段
setScript({
...script,
code: preset,
@@ -326,7 +277,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
userId: undefined,
});
} else if (presetName === TEMPLATE_KEYS.GENERAL) {
// 通用:保留 apiKey 和 baseUrl清空 NewAPI 字段
setScript({
...script,
code: preset,
@@ -334,84 +284,131 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
userId: undefined,
});
} else if (presetName === TEMPLATE_KEYS.NEW_API) {
// NewAPI清空 apiKeyNewAPI 不使用通用的 apiKey
setScript({
...script,
code: preset,
apiKey: undefined,
});
}
setSelectedTemplate(presetName); // 记录选择的模板
setSelectedTemplate(presetName);
}
};
// 判断是否应该显示凭证配置区域
const shouldShowCredentialsConfig =
selectedTemplate === TEMPLATE_KEYS.GENERAL ||
selectedTemplate === TEMPLATE_KEYS.NEW_API;
const footer = (
<>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={handleTest}
disabled={!script.enabled || testing}
>
<Play size={14} className="mr-1" />
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={!script.enabled}
title={t("usageScript.format")}
>
<Wand2 size={14} className="mr-1" />
{t("usageScript.format")}
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={onClose}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Save size={16} className="mr-2" />
{t("usageScript.saveConfig")}
</Button>
</div>
</>
);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{t("usageScript.title")} - {provider.name}
</DialogTitle>
</DialogHeader>
<FullScreenPanel
isOpen={isOpen}
title={`${t("usageScript.title")} - ${provider.name}`}
onClose={onClose}
footer={footer}
>
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
setScript({ ...script, enabled: checked })
}
aria-label={t("usageScript.enableUsageQuery")}
/>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 启用开关 */}
<div className="flex items-center 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">
{t("usageScript.enableUsageQuery")}
</p>
{script.enabled && (
<div className="space-y-6">
{/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<span className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</span>
</div>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
return (
<Button
key={name}
type="button"
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"rounded-lg border",
isSelected
? "shadow-sm"
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() => handleUsePreset(name)}
>
{t(TEMPLATE_NAME_KEYS[name])}
</Button>
);
})}
</div>
<Switch
checked={script.enabled}
onCheckedChange={(checked) =>
setScript({ ...script, enabled: checked })
}
aria-label={t("usageScript.enableUsageQuery")}
/>
</div>
{script.enabled && (
<>
{/* 预设模板选择 */}
<div>
<Label className="mb-2">
{t("usageScript.presetTemplate")}
</Label>
<div className="flex gap-2">
{Object.keys(PRESET_TEMPLATES).map((name) => {
const isSelected = selectedTemplate === name;
return (
<button
key={name}
onClick={() => handleUsePreset(name)}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
isSelected
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t(TEMPLATE_NAME_KEYS[name])}
</button>
);
})}
</div>
</div>
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
{/* 凭证配置区域:通用和 NewAPI 模板显示 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{t("usageScript.credentialsConfig")}
</h4>
{/* 通用模板:显示 apiKey + baseUrl */}
<div className="grid gap-4 md:grid-cols-2">
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
<>
<div className="space-y-2">
@@ -426,12 +423,13 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder="sk-xxxxx"
autoComplete="off"
className="border-white/10"
/>
{script.apiKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
@@ -459,12 +457,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder="https://api.example.com"
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
{/* NewAPI 模板:显示 baseUrl + accessToken + userId */}
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
<>
<div className="space-y-2">
@@ -478,6 +476,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder="https://api.newapi.com"
autoComplete="off"
className="border-white/10"
/>
</div>
@@ -500,6 +499,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
"usageScript.accessTokenPlaceholder",
)}
autoComplete="off"
className="border-white/10"
/>
{script.accessToken && (
<button
@@ -507,7 +507,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
onClick={() =>
setShowAccessToken(!showAccessToken)
}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showAccessToken
? t("apiKeyInput.hide")
@@ -537,32 +537,70 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
}
placeholder={t("usageScript.userIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
</div>
)}
</div>
)}
</div>
{/* 脚本编辑器 */}
<div>
<Label className="mb-2">{t("usageScript.queryScript")}</Label>
<JsonEditor
value={script.code}
onChange={(code) => setScript({ ...script, code })}
height="300px"
language="javascript"
{/* 脚本配置 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<h4 className="text-base font-medium text-foreground">
{t("usageScript.scriptConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.variablesHint")}
</p>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="usage-request-url">
{t("usageScript.requestUrl")}
</Label>
<Input
id="usage-request-url"
type="text"
value={script.request?.url || ""}
onChange={(e) => {
setScript({
...script,
request: { ...script.request, url: e.target.value },
});
}}
placeholder={t("usageScript.requestUrlPlaceholder")}
className="border-white/10"
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{t("usageScript.variablesHint", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</p>
</div>
{/* 配置选项 */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="usage-method">
{t("usageScript.method")}
</Label>
<Input
id="usage-method"
type="text"
value={script.request?.method || "GET"}
onChange={(e) => {
setScript({
...script,
request: {
...script.request,
method: e.target.value.toUpperCase(),
},
});
}}
placeholder="GET / POST"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
@@ -570,83 +608,150 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
<Input
id="usage-timeout"
type="number"
value={script.timeout ?? ""}
onChange={(e) => {
// 输入时:只清理格式,允许临时为空,避免强制回填默认值
const cleaned = sanitizeNumberInput(e.target.value);
setScript((prev) => ({
...prev,
timeout:
cleaned === "" ? undefined : parseInt(cleaned, 10),
}));
}}
onBlur={(e) => {
// 失焦时:严格验证并约束范围
const validated = validateTimeout(e.target.value);
setScript({ ...script, timeout: validated });
}}
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.timeoutHint") || "范围: 2-30 秒"}
</p>
</div>
{/* 🆕 自动查询间隔 */}
<div className="space-y-2">
<Label htmlFor="usage-auto-interval">
{t("usageScript.autoQueryInterval")}
</Label>
<Input
id="usage-auto-interval"
type="number"
min={0}
max={1440}
step={1}
value={script.autoQueryInterval ?? ""}
onChange={(e) => {
// 输入时:只清理格式,允许临时为空
const cleaned = sanitizeNumberInput(e.target.value);
setScript((prev) => ({
...prev,
autoQueryInterval:
cleaned === "" ? undefined : parseInt(cleaned, 10),
}));
}}
onBlur={(e) => {
// 失焦时:严格验证并约束范围
const validated = validateAndClampInterval(
e.target.value,
);
setScript({ ...script, autoQueryInterval: validated });
}}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
{/* 脚本说明 */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<h4 className="font-medium mb-2">
{t("usageScript.scriptHelp")}
</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-white/50 dark:bg-black/20 rounded text-[10px] overflow-x-auto">
{`({
<div className="space-y-2">
<Label htmlFor="usage-headers">
{t("usageScript.headers")}
</Label>
<JsonEditor
id="usage-headers"
value={
script.request?.headers
? JSON.stringify(script.request.headers, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed = JSON.parse(value || "{}");
setScript({
...script,
request: { ...script.request, headers: parsed },
});
} catch (error) {
console.error("Invalid headers JSON", error);
}
}}
height={180}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-body">{t("usageScript.body")}</Label>
<JsonEditor
id="usage-body"
value={
script.request?.body
? JSON.stringify(script.request.body, null, 2)
: "{}"
}
onChange={(value) => {
try {
const parsed =
value?.trim() === "" ? undefined : JSON.parse(value);
setScript({
...script,
request: { ...script.request, body: parsed },
});
} catch (error) {
toast.error(
t("usageScript.invalidJson") || "Body 必须是合法 JSON",
);
}
}}
height={220}
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-interval">
{t("usageScript.autoIntervalMinutes")}
</Label>
<Input
id="usage-interval"
type="number"
min={0}
max={1440}
value={script.autoIntervalMinutes ?? 0}
onChange={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
}
onBlur={(e) =>
setScript({
...script,
autoIntervalMinutes: validateAndClampInterval(
e.target.value,
),
})
}
className="border-white/10"
/>
<p className="text-xs text-muted-foreground">
{t("usageScript.autoQueryIntervalHint")}
</p>
</div>
</div>
</div>
{/* 提取器代码 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
{t("usageScript.extractorCode")}
</Label>
<div className="text-xs text-muted-foreground">
{t("usageScript.extractorHint")}
</div>
</div>
<JsonEditor
id="usage-code"
value={script.code || ""}
onChange={(value) => setScript({ ...script, code: value })}
height={480}
language="javascript"
showMinimap={false}
/>
</div>
{/* 帮助信息 */}
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
<h4 className="font-medium mb-2">{t("usageScript.scriptHelp")}</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
{`({
request: {
url: "{{baseUrl}}/api/usage",
method: "POST",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
},
body: JSON.stringify({ key: "value" }) // ${t("usageScript.commentOptional")}
}
},
extractor: function(response) {
// ${t("usageScript.commentResponseIsJson")}
return {
isValid: !response.error,
remaining: response.balance,
@@ -654,79 +759,41 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
};
}
})`}
</pre>
</div>
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div className="text-gray-600 dark:text-gray-400">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
</div>
</div>
</pre>
</div>
</>
)}
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div className="text-muted-foreground">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
</div>
</div>
</div>
</div>
{/* Footer */}
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-3 pt-4">
{/* Left side - Test and Format buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={!script.enabled || testing}
>
<Play size={14} />
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={!script.enabled}
title={t("usageScript.format")}
>
<Wand2 size={14} />
{t("usageScript.format")}
</Button>
</div>
{/* Right side - Cancel and Save buttons */}
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button variant="default" size="sm" onClick={handleSave}>
{t("usageScript.saveConfig")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</FullScreenPanel>
);
};

View File

@@ -0,0 +1,22 @@
import { Bot } from "lucide-react";
interface AgentsPanelProps {
onOpenChange: (open: boolean) => void;
}
export function AgentsPanel({}: AgentsPanelProps) {
return (
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)]">
<div className="flex-1 glass-card rounded-xl p-8 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-20 h-20 rounded-full bg-white/5 flex items-center justify-center mb-4 animate-pulse-slow">
<Bot className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold">Coming Soon</h3>
<p className="text-muted-foreground max-w-md">
The Agents management feature is currently under development. Stay
tuned for powerful autonomous capabilities.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React from "react";
import { createPortal } from "react-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
interface FullScreenPanelProps {
isOpen: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
}
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
* Uses solid theme colors without transparency
*/
export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
isOpen,
title,
onClose,
children,
footer,
}) => {
React.useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Header */}
<div
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button type="button" variant="outline" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
{children}
</div>
</div>
{/* Footer */}
{footer && (
<div
className="flex-shrink-0 py-4 border-t border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
{footer}
</div>
</div>
)}
</div>,
document.body,
);
};

View File

@@ -110,7 +110,7 @@ export function EnvWarningBanner({
return (
<>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/50">
<div className="fixed top-0 left-0 right-0 z-[100] bg-yellow-50 dark:bg-yellow-950 border-b border-yellow-200 dark:border-yellow-900 shadow-lg animate-slide-down">
<div className="container mx-auto px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
@@ -241,7 +241,7 @@ export function EnvWarningBanner({
</div>
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-md" zIndex="top">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />

View File

@@ -1,25 +1,11 @@
import React, { useMemo, useState } from "react";
import React, { useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Save,
Plus,
AlertCircle,
ChevronDown,
ChevronUp,
Wand2,
} from "lucide-react";
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import JsonEditor from "@/components/JsonEditor";
import type { AppId } from "@/lib/api/types";
import { McpServer, McpServerSpec } from "@/types";
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
@@ -34,25 +20,21 @@ import {
mcpServerToToml,
} from "@/utils/tomlUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
import { formatJSON, parseSmartMcpJson } from "@/utils/formatters";
import { parseSmartMcpJson } from "@/utils/formatters";
import { useMcpValidation } from "./useMcpValidation";
import { useUpsertMcpServer } from "@/hooks/useMcp";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
interface McpFormModalProps {
editingId?: string;
initialData?: McpServer;
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
onSave: () => Promise<void>;
onClose: () => void;
existingIds?: string[];
defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON
defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为全部应用)
defaultFormat?: "json" | "toml";
defaultEnabledApps?: AppId[];
}
/**
* MCP 表单模态框组件v3.7.0 完整重构版)
* - 支持 JSON 和 TOML 两种格式
* - 统一管理,通过复选框选择启用到哪些应用
*/
const McpFormModal: React.FC<McpFormModalProps> = ({
editingId,
initialData,
@@ -79,7 +61,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
// 启用状态:编辑模式使用现有值,新增模式使用默认值
const [enabledApps, setEnabledApps] = useState<{
claude: boolean;
codex: boolean;
@@ -88,7 +69,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
if (initialData?.apps) {
return { ...initialData.apps };
}
// 新增模式:根据 defaultEnabledApps 设置初始值
return {
claude: defaultEnabledApps.includes("claude"),
codex: defaultEnabledApps.includes("codex"),
@@ -96,10 +76,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
});
// 编辑模式下禁止修改 ID
const isEditing = !!editingId;
// 判断是否在编辑模式下有附加信息
const hasAdditionalInfo = !!(
initialData?.description ||
initialData?.tags?.length ||
@@ -107,21 +85,17 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
initialData?.docs
);
// 附加信息展开状态(编辑模式下有值时默认展开)
const [showMetadata, setShowMetadata] = useState(
isEditing ? hasAdditionalInfo : false,
);
// 配置格式:优先使用 defaultFormat编辑模式下可从现有数据推断
const useTomlFormat = useMemo(() => {
if (initialData?.server) {
// 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON
return defaultFormat === "toml";
}
return defaultFormat === "toml";
}, [defaultFormat, initialData]);
// 根据格式决定初始配置
const [formConfig, setFormConfig] = useState(() => {
const spec = initialData?.server;
if (!spec) return "";
@@ -135,8 +109,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
// 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮)
const useToml = useTomlFormat;
const wizardInitialSpec = useMemo(() => {
@@ -164,7 +153,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}, [formConfig, initialData, useToml]);
// 预设选择状态(仅新增模式显示;-1 表示自定义)
const [selectedPreset, setSelectedPreset] = useState<number | null>(
isEditing ? null : -1,
);
@@ -186,7 +174,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return `${candidate}-${i}`;
};
// 应用预设(写入表单但不落库)
const applyPreset = (index: number) => {
if (index < 0 || index >= mcpPresets.length) return;
const preset = mcpPresets[index];
@@ -200,7 +187,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setFormDocs(presetWithDesc.docs || "");
setFormTags(presetWithDesc.tags?.join(", ") || "");
// 根据格式转换配置
if (useToml) {
const toml = mcpServerToToml(presetWithDesc.server);
setFormConfig(toml);
@@ -213,10 +199,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSelectedPreset(index);
};
// 切回自定义
const applyCustom = () => {
setSelectedPreset(-1);
// 恢复到空白模板
setFormId("");
setFormName("");
setFormDescription("");
@@ -228,19 +212,16 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
const handleConfigChange = (value: string) => {
// 若为 TOML 模式,先做引号归一化,避免中文输入法导致的格式错误
const nextValue = useToml ? normalizeTomlText(value) : value;
setFormConfig(nextValue);
if (useToml) {
// TOML validation (use hook's complete validation)
const err = validateTomlConfig(nextValue);
if (err) {
setConfigError(err);
return;
}
// Try to extract ID (if user hasn't filled it yet)
if (nextValue.trim() && !formId.trim()) {
const extractedId = extractIdFromToml(nextValue);
if (extractedId) {
@@ -248,11 +229,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON validation with smart parsing
try {
const result = parseSmartMcpJson(value);
// 验证解析后的配置对象
const configJson = JSON.stringify(result.config);
const validationErr = validateJsonConfig(configJson);
@@ -261,20 +239,15 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return;
}
// 自动填充提取的 id仅当表单 id 为空且不在编辑模式时)
if (result.id && !formId.trim() && !isEditing) {
const uniqueId = ensureUniqueId(result.id);
setFormId(uniqueId);
// 如果 name 也为空,同时填充 name
if (!formName.trim()) {
setFormName(result.id);
}
}
// 不在输入时自动格式化,保持用户输入的原样
// 格式清理将在提交时进行
setConfigError("");
} catch (err: any) {
const errorMessage = err?.message || String(err);
@@ -283,30 +256,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
};
const handleFormatJson = () => {
if (!formConfig.trim()) return;
try {
const formatted = formatJSON(formConfig);
setFormConfig(formatted);
toast.success(t("common.formatSuccess"));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
error: errorMessage,
}),
);
}
};
const handleWizardApply = (title: string, json: string) => {
setFormId(title);
if (!formName.trim()) {
setFormName(title);
}
// Wizard returns JSON, convert based on format if needed
if (useToml) {
try {
const server = JSON.parse(json) as McpServerSpec;
@@ -329,17 +283,14 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return;
}
// 新增模式:阻止提交重名 ID
if (!isEditing && existingIds.includes(trimmedId)) {
setIdError(t("mcp.error.idExists"));
return;
}
// Validate configuration format
let serverSpec: McpServerSpec;
if (useToml) {
// TOML mode
const tomlError = validateTomlConfig(formConfig);
setConfigError(tomlError);
if (tomlError) {
@@ -348,7 +299,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -365,9 +315,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
} else {
// JSON mode
if (!formConfig.trim()) {
// Empty configuration
serverSpec = {
type: "stdio",
command: "",
@@ -375,7 +323,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
};
} else {
try {
// 使用智能解析器,支持带外层键的格式
const result = parseSmartMcpJson(formConfig);
serverSpec = result.config as McpServerSpec;
} catch (e: any) {
@@ -387,7 +334,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
}
// 前置必填校验
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return;
@@ -402,7 +348,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
setSaving(true);
try {
// 先处理 name 字段(必填)
const nameTrimmed = (formName || trimmedId).trim();
const finalName = nameTrimmed || trimmedId;
@@ -411,7 +356,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
id: trimmedId,
name: finalName,
server: serverSpec,
// 使用表单中的启用状态v3.7.0 完整重构)
apps: enabledApps,
};
@@ -446,10 +390,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
delete entry.tags;
}
// 保存到统一配置
await upsertMutation.mutateAsync(entry);
toast.success(t("common.success"));
await onSave(); // 通知父组件关闭表单
await onSave();
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
@@ -466,18 +409,33 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
return (
<>
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{getFormTitle()}</DialogTitle>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
<FullScreenPanel
isOpen={true}
title={getFormTitle()}
onClose={onClose}
footer={
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
}
>
<div className="flex flex-col h-full gap-6">
{/* 上半部分:表单字段 */}
<div className="glass rounded-xl p-6 border border-white/10 space-y-6 flex-shrink-0">
{/* 预设选择(仅新增时展示) */}
{!isEditing && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
<label className="block text-sm font-medium text-foreground mb-3">
{t("mcp.presets.title")}
</label>
<div className="flex flex-wrap gap-2">
@@ -487,7 +445,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === -1
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
>
{t("presetSelector.custom")}
@@ -502,7 +460,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === idx
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
title={t(descriptionKey)}
>
@@ -513,10 +471,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
</div>
)}
{/* ID (标题) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
<label className="block text-sm font-medium text-foreground">
{t("mcp.form.title")} <span className="text-red-500">*</span>
</label>
{!isEditing && idError && (
@@ -536,7 +495,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.name")}
</label>
<Input
@@ -547,9 +506,9 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* 启用到哪些应用v3.7.0 新增) */}
{/* 启用到哪些应用 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
<label className="block text-sm font-medium text-foreground mb-3">
{t("mcp.form.enabledApps")}
</label>
<div className="flex flex-wrap gap-4">
@@ -563,7 +522,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
<label
htmlFor="enable-claude"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
@@ -579,7 +538,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
<label
htmlFor="enable-codex"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
@@ -595,7 +554,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
<label
htmlFor="enable-gemini"
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
@@ -608,7 +567,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
<button
type="button"
onClick={() => setShowMetadata(!showMetadata)}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showMetadata ? (
<ChevronUp size={16} />
@@ -622,9 +581,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{/* 附加信息区域(可折叠) */}
{showMetadata && (
<>
{/* Description (描述) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.description")}
</label>
<Input
@@ -635,9 +593,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.tags")}
</label>
<Input
@@ -648,9 +605,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Homepage */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.homepage")}
</label>
<Input
@@ -661,9 +617,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
/>
</div>
{/* Docs */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground mb-2">
{t("mcp.form.docs")}
</label>
<Input
@@ -675,79 +630,51 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
</>
)}
</div>
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{useToml
? t("mcp.form.tomlConfig")
: t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
</div>
<Textarea
className="h-48 resize-none font-mono text-xs"
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
value={formConfig}
onChange={(e) => handleConfigChange(e.target.value)}
/>
{/* 格式化按钮(仅 JSON 模式) */}
{!useToml && (
<div className="flex items-center justify-between mt-2">
<button
type="button"
onClick={handleFormatJson}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format")}
</button>
</div>
{/* 下半部分JSON 配置编辑器 - 自适应剩余高度 */}
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<label className="text-sm font-medium text-foreground">
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
type="button"
onClick={() => setIsWizardOpen(true)}
className="text-sm text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
>
{t("mcp.form.useWizard")}
</button>
)}
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0">
<JsonEditor
value={formConfig}
onChange={handleConfigChange}
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
darkMode={isDarkMode}
rows={12}
showValidation={!useToml}
language={useToml ? "javascript" : "json"}
height="100%"
/>
</div>
{configError && (
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm flex-shrink-0">
<AlertCircle size={16} />
<span>{configError}</span>
</div>
)}
</div>
</div>
{/* Footer */}
<DialogFooter className="flex justify-end gap-3 pt-4">
{/* 操作按钮 */}
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={saving || (!isEditing && !!idError)}
variant="mcp"
>
{isEditing ? <Save size={16} /> : <Plus size={16} />}
{saving
? t("common.saving")
: isEditing
? t("common.save")
: t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FullScreenPanel>
{/* Wizard Modal */}
<McpWizardModal

View File

@@ -1,14 +1,7 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Server, Check } from "lucide-react";
import { Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp";
import type { McpServer } from "@/types";
@@ -22,7 +15,6 @@ import { mcpPresets } from "@/config/mcpPresets";
import { toast } from "sonner";
interface UnifiedMcpPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
@@ -30,10 +22,14 @@ interface UnifiedMcpPanelProps {
* 统一 MCP 管理面板
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
*/
const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
open,
onOpenChange,
}) => {
export interface UnifiedMcpPanelHandle {
openAdd: () => void;
}
const UnifiedMcpPanel = React.forwardRef<
UnifiedMcpPanelHandle,
UnifiedMcpPanelProps
>(({ onOpenChange: _onOpenChange }, ref) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -90,6 +86,10 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
setIsFormOpen(true);
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const handleDelete = (id: string) => {
setConfirmDialog({
isOpen: true,
@@ -115,78 +115,50 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle>{t("mcp.unifiedPanel.title")}</DialogTitle>
<Button type="button" variant="mcp" onClick={handleAdd}>
<Plus size={16} />
{t("mcp.unifiedPanel.addServer")}
</Button>
</div>
</DialogHeader>
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
</div>
</div>
{/* Info Section */}
<div className="flex-shrink-0 px-6 py-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 pb-4">
{isLoading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("mcp.loading")}
</div>
) : serverEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.unifiedPanel.noServers")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{serverEntries.map(([id, server]) => (
<UnifiedMcpListItem
key={id}
id={id}
server={server}
onToggleApp={handleToggleApp}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
) : serverEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<Server size={24} className="text-gray-400 dark:text-gray-500" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("mcp.unifiedPanel.noServers")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("mcp.emptyDescription")}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="mcp"
onClick={() => onOpenChange(false)}
>
<Check size={16} />
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<div className="space-y-3">
{serverEntries.map(([id, server]) => (
<UnifiedMcpListItem
key={id}
id={id}
server={server}
onToggleApp={handleToggleApp}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Form Modal */}
{isFormOpen && (
@@ -215,9 +187,11 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
onCancel={() => setConfirmDialog(null)}
/>
)}
</>
</div>
);
};
});
UnifiedMcpPanel.displayName = "UnifiedMcpPanel";
/**
* 统一 MCP 列表项组件
@@ -259,112 +233,110 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
};
return (
<div className="min-h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
<div className="flex items-center gap-4">
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{name}
</h3>
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
)}
</div>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
{/* 左侧:服务器信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{name}
</h3>
{docsUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
title={t("mcp.presets.docs")}
>
{t("mcp.presets.docs")}
</Button>
)}
</div>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{description}
</p>
)}
{!description && tags && tags.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
{tags.join(", ")}
</p>
)}
</div>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
<Switch
id={`${id}-claude`}
checked={server.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "claude", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
<Switch
id={`${id}-codex`}
checked={server.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
<Switch
id={`${id}-gemini`}
checked={server.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "gemini", checked)
}
/>
</div>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-claude`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
<Switch
id={`${id}-claude`}
checked={server.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "claude", checked)
}
/>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-codex`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={16} />
</Button>
{t("mcp.unifiedPanel.apps.codex")}
</label>
<Switch
id={`${id}-codex`}
checked={server.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${id}-gemini`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
<Switch
id={`${id}-gemini`}
checked={server.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(id, "gemini", checked)
}
/>
</div>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
title={t("common.edit")}
>
<Edit3 size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("common.delete")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
);

View File

@@ -0,0 +1,153 @@
import React, { 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 MarkdownEditor from "@/components/MarkdownEditor";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Prompt, AppId } from "@/lib/api";
interface PromptFormPanelProps {
appId: AppId;
editingId?: string;
initialData?: Prompt;
onSave: (id: string, prompt: Prompt) => Promise<void>;
onClose: () => void;
}
const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
appId,
editingId,
initialData,
onSave,
onClose,
}) => {
const { t } = useTranslation();
const appName = t(`apps.${appId}`);
const filenameMap: Record<AppId, string> = {
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (initialData) {
setName(initialData.name);
setDescription(initialData.description || "");
setContent(initialData.content);
}
}, [initialData]);
const handleSave = async () => {
if (!name.trim() || !content.trim()) {
return;
}
setSaving(true);
try {
const id = editingId || `prompt-${Date.now()}`;
const timestamp = Math.floor(Date.now() / 1000);
const prompt: Prompt = {
id,
name: name.trim(),
description: description.trim() || undefined,
content: content.trim(),
enabled: initialData?.enabled || false,
createdAt: initialData?.createdAt || timestamp,
updatedAt: timestamp,
};
await onSave(id, prompt);
onClose();
} catch (error) {
// Error handled by hook
} finally {
setSaving(false);
}
};
const title = editingId
? t("prompts.editTitle", { appName })
: t("prompts.addTitle", { appName });
return (
<FullScreenPanel
isOpen={true}
title={title}
onClose={onClose}
footer={
<Button
type="button"
onClick={handleSave}
disabled={!name.trim() || !content.trim() || saving}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? t("common.saving") : t("common.save")}
</Button>
}
>
<div className="glass rounded-xl p-6 border border-white/10 space-y-6">
<div>
<Label htmlFor="name" className="text-foreground">
{t("prompts.name")}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("prompts.namePlaceholder")}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="description" className="text-foreground">
{t("prompts.description")}
</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("prompts.descriptionPlaceholder")}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="content" className="block mb-2 text-foreground">
{t("prompts.content")}
</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder={t("prompts.contentPlaceholder", { filename })}
darkMode={isDarkMode}
minHeight="167px"
/>
</div>
</div>
</FullScreenPanel>
);
};
export default PromptFormPanel;

View File

@@ -25,7 +25,7 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
const enabled = prompt.enabled === true;
return (
<div className="h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
<div className="group relative h-16 rounded-xl border border-border-default bg-muted/50 p-4 transition-all duration-300 hover:bg-muted hover:border-border-default/80 hover:shadow-sm">
<div className="flex items-center gap-4 h-full">
{/* Toggle 开关 */}
<div className="flex-shrink-0">

View File

@@ -1,18 +1,10 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, FileText, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FileText } from "lucide-react";
import { type AppId } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import PromptListItem from "./PromptListItem";
import PromptFormModal from "./PromptFormModal";
import PromptFormPanel from "./PromptFormPanel";
import { ConfirmDialog } from "../ConfirmDialog";
interface PromptPanelProps {
@@ -21,157 +13,143 @@ interface PromptPanelProps {
appId: AppId;
}
const PromptPanel: React.FC<PromptPanelProps> = ({
open,
onOpenChange,
appId,
}) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
titleKey: string;
messageKey: string;
messageParams?: Record<string, unknown>;
onConfirm: () => void;
} | null>(null);
export interface PromptPanelHandle {
openAdd: () => void;
}
const { prompts, loading, reload, savePrompt, deletePrompt, toggleEnabled } =
usePromptActions(appId);
const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
({ open, appId }, ref) => {
const { t } = useTranslation();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
titleKey: string;
messageKey: string;
messageParams?: Record<string, unknown>;
onConfirm: () => void;
} | null>(null);
useEffect(() => {
if (open) reload();
}, [open, reload]);
const {
prompts,
loading,
reload,
savePrompt,
deletePrompt,
toggleEnabled,
} = usePromptActions(appId);
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
useEffect(() => {
if (open) reload();
}, [open, reload]);
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const handleAdd = () => {
setEditingId(null);
setIsFormOpen(true);
};
const handleDelete = (id: string) => {
const prompt = prompts[id];
setConfirmDialog({
isOpen: true,
titleKey: "prompts.confirm.deleteTitle",
messageKey: "prompts.confirm.deleteMessage",
messageParams: { name: prompt?.name },
onConfirm: async () => {
try {
await deletePrompt(id);
setConfirmDialog(null);
} catch (e) {
// Error handled by hook
}
},
});
};
React.useImperativeHandle(ref, () => ({
openAdd: handleAdd,
}));
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
const handleEdit = (id: string) => {
setEditingId(id);
setIsFormOpen(true);
};
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
const handleDelete = (id: string) => {
const prompt = prompts[id];
setConfirmDialog({
isOpen: true,
titleKey: "prompts.confirm.deleteTitle",
messageKey: "prompts.confirm.deleteMessage",
messageParams: { name: prompt?.name },
onConfirm: async () => {
try {
await deletePrompt(id);
setConfirmDialog(null);
} catch (e) {
// Error handled by hook
}
},
});
};
const appName = t(`apps.${appId}`);
const panelTitle = t("prompts.title", { appName });
const promptEntries = useMemo(() => Object.entries(prompts), [prompts]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between pr-8">
<DialogTitle>{panelTitle}</DialogTitle>
<Button type="button" variant="mcp" onClick={handleAdd}>
<Plus size={16} />
{t("prompts.add")}
</Button>
</div>
</DialogHeader>
const enabledPrompt = promptEntries.find(([_, p]) => p.enabled);
<div className="flex-shrink-0 px-6 py-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t("prompts.count", { count: promptEntries.length })} ·{" "}
{enabledPrompt
? t("prompts.enabledName", { name: enabledPrompt[1].name })
: t("prompts.noneEnabled")}
</div>
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{t("prompts.count", { count: promptEntries.length })} ·{" "}
{enabledPrompt
? t("prompts.enabledName", { name: enabledPrompt[1].name })
: t("prompts.noneEnabled")}
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-4">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("prompts.loading")}
<div className="flex-1 overflow-y-auto pb-16">
{loading ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t("prompts.loading")}
</div>
) : promptEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
) : promptEntries.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<FileText
size={24}
className="text-gray-400 dark:text-gray-500"
/>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("prompts.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("prompts.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{promptEntries.map(([id, prompt]) => (
<PromptListItem
key={id}
id={id}
prompt={prompt}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
{t("prompts.empty")}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("prompts.emptyDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{promptEntries.map(([id, prompt]) => (
<PromptListItem
key={id}
id={id}
prompt={prompt}
onToggle={toggleEnabled}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="mcp"
onClick={() => onOpenChange(false)}
>
<Check size={16} />
{t("common.done")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isFormOpen && (
<PromptFormPanel
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? prompts[editingId] : undefined}
onSave={savePrompt}
onClose={() => setIsFormOpen(false)}
/>
)}
{isFormOpen && (
<PromptFormModal
appId={appId}
editingId={editingId || undefined}
initialData={editingId ? prompts[editingId] : undefined}
onSave={savePrompt}
onClose={() => setIsFormOpen(false)}
/>
)}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={t(confirmDialog.titleKey)}
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</div>
);
},
);
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={t(confirmDialog.titleKey)}
message={t(confirmDialog.messageKey, confirmDialog.messageParams)}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
</>
);
};
PromptPanel.displayName = "PromptPanel";
export default PromptPanel;

View File

@@ -1,15 +1,8 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Provider, CustomEndpoint } from "@/types";
import type { AppId } from "@/lib/api";
import {
@@ -48,6 +41,8 @@ export function AddProviderDialog({
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
icon: values.icon?.trim() || undefined,
iconColor: values.iconColor?.trim() || undefined,
...(values.presetCategory ? { category: values.presetCategory } : {}),
...(values.meta ? { meta: values.meta } : {}),
};
@@ -58,8 +53,6 @@ export function AddProviderDialog({
if (!hasCustomEndpoints) {
// 收集端点候选(仅在缺少自定义端点时兜底)
// 1. 从预设配置中获取 endpointCandidates
// 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url)
const urlSet = new Set<string>();
const addUrl = (rawUrl?: string) => {
@@ -170,34 +163,40 @@ export function AddProviderDialog({
? t("provider.addCodexProvider")
: t("provider.addGeminiProvider");
const footer = (
<>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form="provider-form"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4 mr-2" />
{t("common.add")}
</Button>
</>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<DialogTitle>{submitLabel}</DialogTitle>
<DialogDescription>{t("provider.addProviderHint")}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" form="provider-form">
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<FullScreenPanel
isOpen={open}
title={submitLabel}
onClose={() => onOpenChange(false)}
footer={footer}
>
<ProviderForm
appId={appId}
submitLabel={t("common.add")}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}
showButtons={false}
/>
</FullScreenPanel>
);
}

View File

@@ -1,15 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Provider } from "@/types";
import {
ProviderForm,
@@ -34,7 +27,7 @@ export function EditProviderDialog({
}: EditProviderDialogProps) {
const { t } = useTranslation();
// 默认使用传入的 provider.settingsConfig若当前编辑对象是当前生效供应商,则尝试读取实时配置替换初始值
// 默认使用传入的 provider.settingsConfig若当前编辑对象是"当前生效供应商",则尝试读取实时配置替换初始值
const [liveSettings, setLiveSettings] = useState<Record<
string,
unknown
@@ -96,6 +89,8 @@ export function EditProviderDialog({
notes: values.notes?.trim() || undefined,
websiteUrl: values.websiteUrl?.trim() || undefined,
settingsConfig: parsedConfig,
icon: values.icon?.trim() || undefined,
iconColor: values.iconColor?.trim() || undefined,
...(values.presetCategory ? { category: values.presetCategory } : {}),
// 保留或更新 meta 字段
...(values.meta ? { meta: values.meta } : {}),
@@ -112,45 +107,40 @@ export function EditProviderDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] min-h-[600px] flex flex-col">
<DialogHeader>
<DialogTitle>{t("provider.editProvider")}</DialogTitle>
<DialogDescription>
{t("provider.editProviderHint")}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<ProviderForm
appId={appId}
providerId={provider.id}
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,
}}
showButtons={false}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" form="provider-form">
<Save className="h-4 w-4" />
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<FullScreenPanel
isOpen={open}
title={t("provider.editProvider")}
onClose={() => onOpenChange(false)}
footer={
<Button
type="submit"
form="provider-form"
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Save className="h-4 w-4 mr-2" />
{t("common.save")}
</Button>
}
>
<ProviderForm
appId={appId}
providerId={provider.id}
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,
}}
showButtons={false}
/>
</FullScreenPanel>
);
}

View File

@@ -1,4 +1,4 @@
import { BarChart3, Check, Edit, Play, Trash2 } from "lucide-react";
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -7,6 +7,7 @@ interface ProviderActionsProps {
isCurrent: boolean;
onSwitch: () => void;
onEdit: () => void;
onDuplicate: () => void;
onConfigureUsage: () => void;
onDelete: () => void;
}
@@ -15,20 +16,22 @@ export function ProviderActions({
isCurrent,
onSwitch,
onEdit,
onDuplicate,
onConfigureUsage,
onDelete,
}: ProviderActionsProps) {
const { t } = useTranslation();
const iconButtonClass = "h-8 w-8 p-1";
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant={isCurrent ? "secondary" : "default"}
onClick={onSwitch}
disabled={isCurrent}
className={cn(
"w-20",
"w-[4.5rem] px-2.5",
isCurrent &&
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
)}
@@ -52,15 +55,27 @@ export function ProviderActions({
variant="ghost"
onClick={onEdit}
title={t("common.edit")}
className={iconButtonClass}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={onDuplicate}
title={t("provider.duplicate")}
className={iconButtonClass}
>
<Copy className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={onConfigureUsage}
title={t("provider.configureUsage")}
className={iconButtonClass}
>
<BarChart3 className="h-4 w-4" />
</Button>
@@ -71,6 +86,7 @@ export function ProviderActions({
onClick={isCurrent ? undefined : onDelete}
title={t("common.delete")}
className={cn(
iconButtonClass,
!isCurrent && "hover:text-red-500 dark:hover:text-red-400",
isCurrent && "opacity-40 cursor-not-allowed text-muted-foreground",
)}

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { MoveVertical, Copy } from "lucide-react";
import { GripVertical } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
DraggableAttributes,
@@ -8,8 +8,8 @@ import type {
import type { Provider } from "@/types";
import type { AppId } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ProviderActions } from "@/components/providers/ProviderActions";
import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
interface DragHandleProps {
@@ -22,7 +22,6 @@ interface ProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isEditMode?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -71,7 +70,6 @@ export function ProviderCard({
provider,
isCurrent,
appId,
isEditMode = false,
onSwitch,
onEdit,
onDelete,
@@ -116,53 +114,40 @@ export function ProviderCard({
return (
<div
className={cn(
"rounded-lg bg-card p-4 shadow-sm",
"transition-[border-color,background-color,box-shadow,ring] duration-200",
"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 border-border-default bg-primary/5 ring-2 ring-blue-500/30 dark:ring-blue-400/30"
: "border border-border-default hover:border-border-hover",
? "border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(59,130,246,0.15)]"
: "hover:scale-[1.01]",
dragHandleProps?.isDragging &&
"cursor-grabbing border-active border-border-dragging shadow-lg",
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
)}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2">
<div
<button
type="button"
className={cn(
"flex items-center gap-1 overflow-hidden",
"transition-[max-width,opacity] duration-200 ease-in-out",
isEditMode ? "max-w-20 opacity-100" : "max-w-0 opacity-0",
"-ml-1.5 flex-shrink-0 cursor-grab active:cursor-grabbing p-1.5",
"text-muted-foreground/50 hover:text-muted-foreground transition-colors",
dragHandleProps?.isDragging && "cursor-grabbing",
)}
aria-hidden={!isEditMode}
aria-label={t("provider.dragHandle")}
{...(dragHandleProps?.attributes ?? {})}
{...(dragHandleProps?.listeners ?? {})}
>
<Button
type="button"
size="icon"
variant="ghost"
className={cn(
"flex-shrink-0 cursor-grab active:cursor-grabbing",
dragHandleProps?.isDragging && "cursor-grabbing",
)}
aria-label={t("provider.dragHandle")}
disabled={!isEditMode}
{...(dragHandleProps?.attributes ?? {})}
{...(dragHandleProps?.listeners ?? {})}
>
<MoveVertical className="h-4 w-4" />
</Button>
<GripVertical className="h-4 w-4" />
</button>
<Button
type="button"
size="icon"
variant="ghost"
className="flex-shrink-0"
onClick={() => onDuplicate(provider)}
disabled={!isEditMode}
aria-label={t("provider.duplicate")}
title={t("provider.duplicate")}
>
<Copy className="h-4 w-4" />
</Button>
{/* 供应商图标 */}
<div className="h-9 w-9 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">
<ProviderIcon
icon={provider.icon}
name={provider.name}
color={provider.iconColor}
size={26}
/>
</div>
<div className="space-y-1">
@@ -210,23 +195,28 @@ export function ProviderCard({
</div>
</div>
<div className="flex items-center gap-3">
<UsageFooter
provider={provider}
providerId={provider.id}
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
inline={true}
/>
<div className="relative flex items-center ml-auto">
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[12.25rem] group-focus-within:-translate-x-[12.25rem] sm:group-hover:-translate-x-[14.25rem] sm:group-focus-within:-translate-x-[14.25rem]">
<UsageFooter
provider={provider}
providerId={provider.id}
appId={appId}
usageEnabled={usageEnabled}
isCurrent={isCurrent}
inline={true}
/>
</div>
<ProviderActions
isCurrent={isCurrent}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
/>
<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}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
onDuplicate={() => onDuplicate(provider)}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
/>
</div>
</div>
</div>
</div>

View File

@@ -16,7 +16,6 @@ interface ProviderListProps {
providers: Record<string, Provider>;
currentProviderId: string;
appId: AppId;
isEditMode?: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -31,7 +30,6 @@ export function ProviderList({
providers,
currentProviderId,
appId,
isEditMode = false,
onSwitch,
onEdit,
onDelete,
@@ -73,14 +71,16 @@ export function ProviderList({
items={sortedProviders.map((provider) => provider.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
<div
className="space-y-3 animate-slide-up"
style={{ animationDelay: "0.1s" }}
>
{sortedProviders.map((provider) => (
<SortableProviderCard
key={provider.id}
provider={provider}
isCurrent={provider.id === currentProviderId}
appId={appId}
isEditMode={isEditMode}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}
@@ -99,7 +99,6 @@ interface SortableProviderCardProps {
provider: Provider;
isCurrent: boolean;
appId: AppId;
isEditMode: boolean;
onSwitch: (provider: Provider) => void;
onEdit: (provider: Provider) => void;
onDelete: (provider: Provider) => void;
@@ -112,7 +111,6 @@ function SortableProviderCard({
provider,
isCurrent,
appId,
isEditMode,
onSwitch,
onEdit,
onDelete,
@@ -140,7 +138,6 @@ function SortableProviderCard({
provider={provider}
isCurrent={isCurrent}
appId={appId}
isEditMode={isEditMode}
onSwitch={onSwitch}
onEdit={onEdit}
onDelete={onDelete}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import {
FormControl,
FormField,
@@ -7,6 +8,17 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import {
Dialog,
DialogContent,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { ProviderIcon } from "@/components/ProviderIcon";
import { IconPicker } from "@/components/IconPicker";
import { getIconMetadata } from "@/icons/extracted/metadata";
import type { UseFormReturn } from "react-hook-form";
import type { ProviderFormData } from "@/lib/schemas/provider";
@@ -16,22 +28,115 @@ interface BasicFormFieldsProps {
export function BasicFormFields({ form }: BasicFormFieldsProps) {
const { t } = useTranslation();
const [iconDialogOpen, setIconDialogOpen] = useState(false);
const currentIcon = form.watch("icon");
const currentIconColor = form.watch("iconColor");
const providerName = form.watch("name") || "Provider";
const effectiveIconColor =
currentIconColor ||
(currentIcon ? getIconMetadata(currentIcon)?.defaultColor : undefined);
const handleIconSelect = (icon: string) => {
const meta = getIconMetadata(icon);
form.setValue("icon", icon);
form.setValue("iconColor", meta?.defaultColor ?? "");
};
return (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.name")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("provider.namePlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 图标选择区域 - 顶部居中,可选 */}
<div className="flex justify-center mb-6">
<Dialog open={iconDialogOpen} onOpenChange={setIconDialogOpen}>
<DialogTrigger asChild>
<button
type="button"
className="w-20 h-20 p-3 rounded-xl border-2 border-gray-300 dark:border-gray-600 hover:border-primary dark:hover:border-primary transition-colors cursor-pointer bg-gray-50 dark:bg-gray-800/50 flex items-center justify-center"
title={currentIcon ? "点击更换图标" : "点击选择图标"}
>
<ProviderIcon
icon={currentIcon}
name={providerName}
color={effectiveIconColor}
size={48}
/>
</button>
</DialogTrigger>
<DialogContent
variant="fullscreen"
zIndex="top"
overlayClassName="bg-[hsl(var(--background))] backdrop-blur-0"
className="p-0 sm:rounded-none"
>
<div className="flex h-full flex-col">
<div className="flex-shrink-0 py-4 border-b border-border-default bg-muted/40">
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<DialogClose asChild>
<Button type="button" variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</DialogClose>
<p className="text-lg font-semibold leading-tight">
{t("providerIcon.selectIcon", {
defaultValue: "选择图标",
})}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-6 mx-auto max-w-[56rem] px-6 py-6 w-full">
<IconPicker
value={currentIcon}
onValueChange={handleIconSelect}
color={effectiveIconColor}
/>
<div className="flex justify-end gap-2">
<DialogClose asChild>
<Button type="button" variant="outline">
{t("common.done", { defaultValue: "完成" })}
</Button>
</DialogClose>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* 基础信息 - 网格布局 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.name")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("provider.namePlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.notes")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("provider.notesPlaceholder")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
@@ -46,20 +151,6 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.notes")}</FormLabel>
<FormControl>
<Input {...field} placeholder={t("provider.notesPlaceholder")} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}

View File

@@ -1,14 +1,9 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import JsonEditor from "@/components/JsonEditor";
interface CodexCommonConfigModalProps {
isOpen: boolean;
@@ -30,47 +25,30 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
error,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t("codexConfig.editCommonConfigTitle")}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("codexConfig.commonConfigHint")}
</p>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
rows={12}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
<DialogFooter>
<FullScreenPanel
isOpen={isOpen}
title={t("codexConfig.editCommonConfigTitle")}
onClose={onClose}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
@@ -78,8 +56,30 @@ export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("codexConfig.commonConfigHint")}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`# Common Codex config
# Add your common TOML configuration here`}
darkMode={isDarkMode}
rows={16}
showValidation={false}
language="javascript"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
);
};

View File

@@ -1,8 +1,6 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
import JsonEditor from "@/components/JsonEditor";
interface CodexAuthSectionProps {
value: string;
@@ -21,23 +19,27 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
error,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const handleFormat = () => {
if (!value.trim()) return;
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleChange = (newValue: string) => {
onChange(newValue);
if (onBlur) {
onBlur();
}
};
@@ -50,39 +52,19 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
{t("codexConfig.authJson")}
</label>
<textarea
id="codexAuth"
<JsonEditor
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
onChange={handleChange}
placeholder={t("codexConfig.authJsonPlaceholder")}
darkMode={isDarkMode}
rows={6}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
showValidation={true}
language="json"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
@@ -116,6 +98,22 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
configError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<div className="space-y-2">
@@ -154,22 +152,14 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</p>
)}
<textarea
id="codexConfig"
<JsonEditor
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={onChange}
placeholder=""
darkMode={isDarkMode}
rows={8}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
showValidation={false}
language="javascript"
/>
{configError && (

View File

@@ -1,16 +1,10 @@
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useEffect, useState } from "react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Save, Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
import { Save } from "lucide-react";
import JsonEditor from "@/components/JsonEditor";
interface CommonConfigEditorProps {
value: string;
@@ -38,44 +32,22 @@ export function CommonConfigEditor({
onModalClose,
}: CommonConfigEditorProps) {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const handleFormatMain = () => {
if (!value.trim()) return;
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
const handleFormatModal = () => {
if (!commonConfigSnippet.trim()) return;
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
try {
const formatted = formatJSON(commonConfigSnippet);
onCommonConfigSnippetChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return () => observer.disconnect();
}, []);
return (
<>
@@ -115,90 +87,30 @@ export function CommonConfigEditor({
{commonConfigError}
</p>
)}
<textarea
id="settingsConfig"
<JsonEditor
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={onChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
}
}`}
darkMode={isDarkMode}
rows={14}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[16rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
showValidation={true}
language="json"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormatMain}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
</div>
</div>
<Dialog
open={isModalOpen}
onOpenChange={(open) => !open && onModalClose()}
>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>
{t("claudeConfig.editCommonConfigTitle", {
defaultValue: "编辑通用配置片段",
})}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<textarea
value={commonConfigSnippet}
onChange={(e) => onCommonConfigSnippetChange(e.target.value)}
rows={12}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[14rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormatModal}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</div>
<DialogFooter>
<FullScreenPanel
isOpen={isModalOpen}
title={t("claudeConfig.editCommonConfigTitle", {
defaultValue: "编辑通用配置片段",
})}
onClose={onModalClose}
footer={
<>
<Button type="button" variant="outline" onClick={onModalClose}>
{t("common.cancel")}
</Button>
@@ -206,9 +118,35 @@ export function CommonConfigEditor({
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("claudeConfig.commonConfigHint", {
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
})}
</p>
<JsonEditor
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
}
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{commonConfigError && (
<p className="text-sm text-red-500 dark:text-red-400">
{commonConfigError}
</p>
)}
</div>
</FullScreenPanel>
</>
);
}

View File

@@ -5,13 +5,7 @@ import type { AppId } from "@/lib/api";
import { vscodeApi } from "@/lib/api/vscode";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { CustomEndpoint, EndpointCandidate } from "@/types";
// 端点测速超时配置(秒)
@@ -431,211 +425,218 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
onClose();
}, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]);
return (
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[80vh] flex flex-col p-0"
if (!visible) return null;
const footer = (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={(event) => {
event.preventDefault();
onClose();
}}
disabled={isSaving}
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>{t("endpointTest.title")}</DialogTitle>
</DialogHeader>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t("common.saving")}
</>
) : (
<>
<Save className="w-4 h-4" />
{t("common.save")}
</>
)}
</Button>
</div>
);
{/* Content */}
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{entries.length} {t("endpointTest.endpoints")}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-border-default "
/>
{t("endpointTest.autoSelect")}
</label>
<Button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
size="sm"
className="h-7 w-20 gap-1.5 text-xs"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("endpointTest.testing")}
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t("endpointTest.testSpeed")}
</>
)}
</Button>
</div>
return (
<FullScreenPanel
isOpen={visible}
title={t("endpointTest.title")}
onClose={onClose}
footer={footer}
>
<div className="glass rounded-xl p-6 border border-white/10 flex flex-col gap-6">
{/* 测速控制栏 */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{entries.length} {t("endpointTest.endpoints")}
</div>
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1"
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
/>
<Button
type="button"
onClick={handleAddEndpoint}
variant="outline"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{addError}
</div>
)}
{t("endpointTest.autoSelect")}
</label>
<Button
type="button"
onClick={runSpeedTest}
disabled={isTesting || !hasEndpoints}
size="sm"
className="h-7 w-24 gap-1.5 text-xs bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-60"
>
{isTesting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("endpointTest.testing")}
</>
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t("endpointTest.testSpeed")}
</>
)}
</Button>
</div>
</div>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
: "border-border-default bg-white hover:border-border-default hover:bg-gray-50 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-800"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div
className={`font-mono text-sm font-medium ${
latency < 300
? "text-green-600 dark:text-green-400"
: latency < 500
? "text-yellow-600 dark:text-yellow-400"
: latency < 800
? "text-orange-600 dark:text-orange-400"
: "text-red-600 dark:text-red-400"
}`}
>
{latency}ms
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400">
{t("endpointTest.failed")}
</div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-border-default bg-gray-50 py-8 text-center text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
{t("endpointTest.noEndpoints")}
</div>
)}
{/* 错误提示 */}
{lastError && (
{/* 添加输入 */}
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
type="url"
value={customUrl}
placeholder={t("endpointTest.addEndpointPlaceholder")}
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1"
/>
<Button
type="button"
onClick={handleAddEndpoint}
variant="outline"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{addError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
{addError}
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSaving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t("common.saving")}
</>
) : (
<>
<Save className="w-4 h-4" />
{t("common.save")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 端点列表 */}
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
return (
<div
key={entry.id}
onClick={() => handleSelect(entry.url)}
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
isSelected
? "border-primary/70 bg-primary/5 shadow-sm"
: "border-border-default bg-background hover:bg-muted"
}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* 选择指示器 */}
<div
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
isSelected
? "bg-blue-500 dark:bg-blue-400"
: "bg-gray-300 dark:bg-gray-700"
}`}
/>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
{entry.url}
</div>
</div>
</div>
{/* 右侧信息 */}
<div className="flex items-center gap-2">
{latency !== null ? (
<div className="text-right">
<div
className={`font-mono text-sm font-medium ${
latency < 300
? "text-emerald-600 dark:text-emerald-400"
: latency < 500
? "text-yellow-600 dark:text-yellow-400"
: latency < 800
? "text-orange-600 dark:text-orange-400"
: "text-red-600 dark:text-red-400"
}`}
>
{latency}ms
</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">
{entry.status
? t("endpointTest.status", { code: entry.status })
: t("endpointTest.notTested")}
</div>
</div>
) : isTesting ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
) : entry.error ? (
<div className="text-xs text-gray-400">
{t("endpointTest.failed")}
</div>
) : (
<div className="text-xs text-gray-400"></div>
)}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleRemoveEndpoint(entry);
}}
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-border-default bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
{t("endpointTest.empty")}
</div>
)}
{/* 错误提示 */}
{lastError && (
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="h-3 w-3" />
{lastError}
</div>
)}
</div>
</FullScreenPanel>
);
};

View File

@@ -1,16 +1,9 @@
import React from "react";
import { Save, Wand2 } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import { formatJSON } from "@/utils/formatters";
import JsonEditor from "@/components/JsonEditor";
interface GeminiCommonConfigModalProps {
isOpen: boolean;
@@ -28,86 +21,32 @@ export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
> = ({ isOpen, onClose, value, onChange, error }) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const handleFormat = () => {
if (!value.trim()) return;
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>
{t("geminiConfig.editCommonConfigTitle", {
defaultValue: "编辑 Gemini 通用配置片段",
})}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
})}
</p>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`{
"timeout": 30000,
"maxRetries": 3,
"customField": "value"
}`}
rows={12}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</div>
<DialogFooter>
<FullScreenPanel
isOpen={isOpen}
title={t("geminiConfig.editCommonConfigTitle", {
defaultValue: "编辑 Gemini 通用配置片段",
})}
onClose={onClose}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
@@ -115,8 +54,35 @@ export const GeminiCommonConfigModal: React.FC<
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
})}
</p>
<JsonEditor
value={value}
onChange={onChange}
placeholder={`{
"timeout": 30000,
"maxRetries": 3,
"customField": "value"
}`}
darkMode={isDarkMode}
rows={16}
showValidation={true}
language="json"
/>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</FullScreenPanel>
);
};

View File

@@ -1,8 +1,6 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
import JsonEditor from "@/components/JsonEditor";
interface GeminiEnvSectionProps {
value: string;
@@ -21,27 +19,27 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
error,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const handleFormat = () => {
if (!value.trim()) return;
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
try {
// 重新格式化 .env 内容
const formatted = value
.split("\n")
.filter((line) => line.trim())
.join("\n");
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleChange = (newValue: string) => {
onChange(newValue);
if (onBlur) {
onBlur();
}
};
@@ -54,41 +52,21 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
<textarea
id="geminiEnv"
<JsonEditor
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
onChange={handleChange}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
GEMINI_API_KEY=sk-your-api-key-here
GEMINI_MODEL=gemini-3-pro-preview`}
darkMode={isDarkMode}
rows={6}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
showValidation={false}
language="javascript"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
@@ -124,25 +102,22 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
configError,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
const handleFormat = () => {
if (!value.trim()) return;
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return (
<div className="space-y-2">
@@ -187,43 +162,22 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
</p>
)}
<textarea
id="geminiConfig"
<JsonEditor
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={onChange}
placeholder={`{
"timeout": 30000,
"maxRetries": 3
}`}
darkMode={isDarkMode}
rows={8}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
showValidation={true}
language="json"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">
{configError}
</p>
)}
</div>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
)}
{!configError && (
<p className="text-xs text-gray-500 dark:text-gray-400">

View File

@@ -78,6 +78,8 @@ interface ProviderFormProps {
settingsConfig?: Record<string, unknown>;
category?: ProviderCategory;
meta?: ProviderMeta;
icon?: string;
iconColor?: string;
};
showButtons?: boolean;
}
@@ -147,6 +149,8 @@ export function ProviderForm({
: appId === "gemini"
? GEMINI_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
[initialData, appId],
);
@@ -651,7 +655,7 @@ export function ProviderForm({
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
className="space-y-6 glass rounded-xl p-6 border border-white/10"
>
{/* 预设供应商选择(仅新增模式显示) */}
{!initialData && (

View File

@@ -99,7 +99,7 @@ export function ProviderPresetSelector({
return `${baseClass} bg-blue-500 text-white dark:bg-blue-600`;
}
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
return `${baseClass} bg-accent text-muted-foreground hover:bg-accent/80`;
};
// 获取预设按钮的内联样式(用于自定义背景色)
@@ -128,7 +128,7 @@ export function ProviderPresetSelector({
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPresetId === "custom"
? "bg-blue-500 text-white dark:bg-blue-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
>
{t("providerPreset.custom")}

View File

@@ -44,66 +44,73 @@ export function ImportExportSection({
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.importExport")}</h3>
<p className="text-xs text-muted-foreground">
<header className="space-y-2">
<h3 className="text-base font-semibold text-foreground">
{t("settings.importExport")}
</h3>
<p className="text-sm text-muted-foreground">
{t("settings.importExportHint")}
</p>
</header>
<div className="space-y-3 rounded-lg border border-border-default p-4">
<Button
type="button"
className="w-full"
variant="secondary"
onClick={onExport}
>
<Save className="mr-2 h-4 w-4" />
{t("settings.exportConfig")}
</Button>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="space-y-4 rounded-xl glass-card p-6 border border-white/10">
{/* Import and Export Buttons Side by Side */}
<div className="grid grid-cols-2 gap-4 items-stretch">
{/* Import Button */}
<div className="relative">
<Button
type="button"
variant="outline"
className="flex-1 min-w-[180px]"
onClick={onSelectFile}
className={`w-full h-auto py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white ${selectedFile && !isImporting ? "flex-col items-start" : "items-center"}`}
onClick={!selectedFile ? onSelectFile : onImport}
disabled={isImporting}
>
<FolderOpen className="mr-2 h-4 w-4" />
{t("settings.selectConfigFile")}
</Button>
<Button
type="button"
disabled={!selectedFile || isImporting}
onClick={onImport}
>
{isImporting ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.importing")}
<div className="flex items-center gap-2 w-full justify-center">
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
) : selectedFile ? (
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
) : (
<FolderOpen className="h-4 w-4 flex-shrink-0" />
)}
<span className="font-medium">
{isImporting
? t("settings.importing")
: selectedFile
? t("settings.import")
: t("settings.selectConfigFile")}
</span>
) : (
t("settings.import")
</div>
{selectedFile && !isImporting && (
<div className="mt-2 w-full text-left">
<p className="text-xs font-mono text-white/80 truncate">
📄 {selectedFileName}
</p>
</div>
)}
</Button>
{selectedFile ? (
<Button type="button" variant="ghost" onClick={onClear}>
<XCircle className="mr-2 h-4 w-4" />
{t("common.clear")}
</Button>
) : null}
{selectedFile && (
<button
type="button"
onClick={onClear}
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
aria-label="Clear selection"
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
{selectedFile ? (
<p className="truncate rounded-md bg-muted/40 px-3 py-2 text-xs font-mono text-muted-foreground">
{selectedFileName}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t("settings.noFileSelected")}
</p>
)}
{/* Export Button */}
<div>
<Button
type="button"
className="w-full h-full py-3 px-4 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white items-center"
onClick={onExport}
>
<Save className="mr-2 h-4 w-4" />
{t("settings.exportConfig")}
</Button>
</div>
</div>
<ImportStatusMessage
@@ -134,15 +141,19 @@ function ImportStatusMessage({
}
const baseClass =
"flex items-start gap-2 rounded-md border px-3 py-2 text-xs leading-relaxed";
"flex items-start gap-3 rounded-xl border p-4 text-sm leading-relaxed backdrop-blur-sm";
if (status === "importing") {
return (
<div className={`${baseClass} border-border-default bg-muted/40`}>
<Loader2 className="mt-0.5 h-4 w-4 animate-spin text-muted-foreground" />
<div
className={`${baseClass} border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400`}
>
<Loader2 className="mt-0.5 h-5 w-5 flex-shrink-0 animate-spin" />
<div>
<p className="font-medium">{t("settings.importing")}</p>
<p className="text-muted-foreground">{t("common.loading")}</p>
<p className="font-semibold">{t("settings.importing")}</p>
<p className="text-blue-600/80 dark:text-blue-400/80">
{t("common.loading")}
</p>
</div>
</div>
);
@@ -151,17 +162,19 @@ function ImportStatusMessage({
if (status === "success") {
return (
<div
className={`${baseClass} border-green-200 bg-green-100/70 text-green-700`}
className={`${baseClass} border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-400`}
>
<CheckCircle2 className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importSuccess")}</p>
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importSuccess")}</p>
{backupId ? (
<p className="text-xs">
<p className="text-xs text-green-600/80 dark:text-green-400/80">
{t("settings.backupId")}: {backupId}
</p>
) : null}
<p>{t("settings.autoReload")}</p>
<p className="text-green-600/80 dark:text-green-400/80">
{t("settings.autoReload")}
</p>
</div>
</div>
);
@@ -170,12 +183,14 @@ function ImportStatusMessage({
if (status === "partial-success") {
return (
<div
className={`${baseClass} border-yellow-200 bg-yellow-100/70 text-yellow-700`}
className={`${baseClass} border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400`}
>
<AlertCircle className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importPartialSuccess")}</p>
<p>{t("settings.importPartialHint")}</p>
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importPartialSuccess")}</p>
<p className="text-yellow-600/80 dark:text-yellow-400/80">
{t("settings.importPartialHint")}
</p>
</div>
</div>
);
@@ -184,11 +199,13 @@ function ImportStatusMessage({
const message = errorMessage || t("settings.importFailed");
return (
<div className={`${baseClass} border-red-200 bg-red-100/70 text-red-600`}>
<AlertCircle className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importFailed")}</p>
<p>{message}</p>
<div
className={`${baseClass} border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400`}
>
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importFailed")}</p>
<p className="text-red-600/80 dark:text-red-400/80">{message}</p>
</div>
</div>
);

View File

@@ -1,295 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { ThemeSettings } from "@/components/settings/ThemeSettings";
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 { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImportSuccess?: () => void | Promise<void>;
}
export function SettingsDialog({
open,
onOpenChange,
onImportSuccess,
}: SettingsDialogProps) {
const { t } = useTranslation();
const {
settings,
isLoading,
isSaving,
isPortable,
appConfigDir,
resolvedDirs,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
saveSettings,
resetSettings,
requiresRestart,
acknowledgeRestart,
} = useSettings();
const {
selectedFile,
status: importStatus,
errorMessage,
backupId,
isImporting,
selectImportFile,
importConfig,
exportConfig,
clearSelection,
resetStatus,
} = useImportExport({ onImportSuccess });
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
useEffect(() => {
if (open) {
setActiveTab("general");
resetStatus();
}
}, [open, resetStatus]);
useEffect(() => {
if (requiresRestart) {
setShowRestartPrompt(true);
}
}, [requiresRestart]);
const closeDialog = useCallback(() => {
// 取消/直接关闭:恢复到初始设置(包括语言回滚)
resetSettings();
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [
acknowledgeRestart,
clearSelection,
onOpenChange,
resetSettings,
resetStatus,
]);
const closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);
const handleDialogChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
closeDialog();
} else {
onOpenChange(true);
}
},
[closeDialog, onOpenChange],
);
const handleCancel = useCallback(() => {
closeDialog();
}, [closeDialog]);
const handleSave = useCallback(async () => {
try {
const result = await saveSettings();
if (!result) return;
if (result.requiresRestart) {
setShowRestartPrompt(true);
return;
}
closeAfterSave();
} catch (error) {
console.error("[SettingsDialog] Failed to save settings", error);
}
}, [closeDialog, saveSettings]);
const handleRestartLater = useCallback(() => {
setShowRestartPrompt(false);
closeAfterSave();
}, [closeAfterSave]);
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(t("settings.devModeRestartHint"));
closeAfterSave();
return;
}
try {
await settingsApi.restart();
} catch (error) {
console.error("[SettingsDialog] Failed to restart app", error);
toast.error(t("settings.restartFailed"));
} finally {
closeAfterSave();
}
}, [closeAfterSave, t]);
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
{isBusy ? (
<div className="flex min-h-[320px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 overflow-y-auto px-6 py-4">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<TabsContent
value="general"
className="space-y-6 mt-6 min-h-[400px]"
>
{settings ? (
<>
<LanguageSettings
value={settings.language}
onChange={(lang) => updateSettings({ language: lang })}
/>
<ThemeSettings />
<WindowSettings
settings={settings}
onChange={updateSettings}
/>
</>
) : null}
</TabsContent>
<TabsContent
value="advanced"
className="space-y-6 mt-6 min-h-[400px]"
>
{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}
/>
</>
) : null}
</TabsContent>
<TabsContent value="about" className="mt-6 min-h-[400px]">
<AboutSection isPortable={isPortable} />
</TabsContent>
</Tabs>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={isSaving || isBusy}>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
<Dialog
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent zIndex="alert" className="max-w-md">
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
<div className="px-6">
<p className="text-sm text-muted-foreground">
{t("settings.restartRequiredMessage")}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleRestartLater}>
{t("settings.restartLater")}
</Button>
<Button onClick={handleRestartNow}>
{t("settings.restartNow")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
);
}

View File

@@ -0,0 +1,284 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { ThemeSettings } from "@/components/settings/ThemeSettings";
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 { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImportSuccess?: () => void | Promise<void>;
}
export function SettingsPage({
open,
onOpenChange,
onImportSuccess,
}: SettingsDialogProps) {
const { t } = useTranslation();
const {
settings,
isLoading,
isSaving,
isPortable,
appConfigDir,
resolvedDirs,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
saveSettings,
autoSaveSettings,
requiresRestart,
acknowledgeRestart,
} = useSettings();
const {
selectedFile,
status: importStatus,
errorMessage,
backupId,
isImporting,
selectImportFile,
importConfig,
exportConfig,
clearSelection,
resetStatus,
} = useImportExport({ onImportSuccess });
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
useEffect(() => {
if (open) {
setActiveTab("general");
resetStatus();
}
}, [open, resetStatus]);
useEffect(() => {
if (requiresRestart) {
setShowRestartPrompt(true);
}
}, [requiresRestart]);
const closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);
const handleSave = useCallback(async () => {
try {
const result = await saveSettings(undefined, { silent: false });
if (!result) return;
if (result.requiresRestart) {
setShowRestartPrompt(true);
return;
}
closeAfterSave();
} catch (error) {
console.error("[SettingsPage] Failed to save settings", error);
}
}, [closeAfterSave, saveSettings]);
const handleRestartLater = useCallback(() => {
setShowRestartPrompt(false);
closeAfterSave();
}, [closeAfterSave]);
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(t("settings.devModeRestartHint"));
closeAfterSave();
return;
}
try {
await settingsApi.restart();
} catch (error) {
console.error("[SettingsPage] Failed to restart app", error);
toast.error(t("settings.restartFailed"));
} finally {
closeAfterSave();
}
}, [closeAfterSave, t]);
// 通用设置即时保存(无需手动点击)
// 使用 autoSaveSettings 避免误触发系统 API开机自启、Claude 插件等)
const handleAutoSave = useCallback(
async (updates: Partial<SettingsFormState>) => {
if (!settings) return;
updateSettings(updates);
try {
await autoSaveSettings(updates);
} catch (error) {
console.error("[SettingsPage] Failed to autosave settings", error);
toast.error(
t("settings.saveFailedGeneric", {
defaultValue: "保存失败,请重试",
}),
);
}
},
[autoSaveSettings, settings, t, updateSettings],
);
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] px-6">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3 mb-6 glass rounded-xl">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto pr-2">
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<>
<LanguageSettings
value={settings.language}
onChange={(lang) => handleAutoSave({ language: lang })}
/>
<ThemeSettings />
<WindowSettings
settings={settings}
onChange={handleAutoSave}
/>
</>
) : null}
</TabsContent>
<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">
<Button
onClick={handleSave}
className="w-full"
disabled={isSaving}
>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</div>
</>
) : null}
</TabsContent>
<TabsContent value="about" className="mt-0">
<AboutSection isPortable={isPortable} />
</TabsContent>
</div>
</Tabs>
)}
<Dialog
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent
zIndex="alert"
className="max-w-md glass border-white/10"
>
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
<div className="px-6">
<p className="text-sm text-muted-foreground">
{t("settings.restartRequiredMessage")}
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={handleRestartLater}
className="hover:bg-white/5"
>
{t("settings.restartLater")}
</Button>
<Button
onClick={handleRestartNow}
className="bg-primary hover:bg-primary/90"
>
{t("settings.restartNow")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -19,6 +19,13 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
</p>
</header>
<ToggleRow
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")}

View File

@@ -0,0 +1,219 @@
import { useState } 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 { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Skill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerPanelProps {
repos: SkillRepo[];
skills: Skill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
onClose: () => void;
}
export function RepoManagerPanel({
repos,
skills,
onAdd,
onRemove,
onClose,
}: RepoManagerPanelProps) {
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [skillsPath, setSkillsPath] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
skills.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
const parseRepoUrl = (
url: string,
): { owner: string; name: string } | null => {
let cleaned = url.trim();
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
cleaned = cleaned.replace(/\.git$/, "");
const parts = cleaned.split("/");
if (parts.length === 2 && parts[0] && parts[1]) {
return { owner: parts[0], name: parts[1] };
}
return null;
};
const handleAdd = async () => {
setError("");
const parsed = parseRepoUrl(repoUrl);
if (!parsed) {
setError(t("skills.repo.invalidUrl"));
return;
}
try {
await onAdd({
owner: parsed.owner,
name: parsed.name,
branch: branch || "main",
enabled: true,
skillsPath: skillsPath.trim() || undefined,
});
setRepoUrl("");
setBranch("");
setSkillsPath("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
};
const handleOpenRepo = async (owner: string, name: string) => {
try {
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
} catch (error) {
console.error("Failed to open URL:", error);
}
};
return (
<FullScreenPanel
isOpen={true}
title={t("skills.repo.title")}
onClose={onClose}
>
{/* 添加仓库表单 */}
<div className="space-y-4 glass rounded-xl p-6 border border-white/10">
<h3 className="text-base font-semibold text-foreground">
</h3>
<div className="space-y-4">
<div>
<Label htmlFor="repo-url" className="text-foreground">
{t("skills.repo.url")}
</Label>
<Input
id="repo-url"
placeholder={t("skills.repo.urlPlaceholder")}
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
className="mt-2"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="branch" className="text-foreground">
{t("skills.repo.branch")}
</Label>
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="skills-path" className="text-foreground">
{t("skills.repo.path")}
</Label>
<Input
id="skills-path"
placeholder={t("skills.repo.pathPlaceholder")}
value={skillsPath}
onChange={(e) => setSkillsPath(e.target.value)}
className="mt-2"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<Button
onClick={handleAdd}
className="bg-primary text-primary-foreground hover:bg-primary/90"
type="button"
>
<Plus className="h-4 w-4 mr-2" />
{t("skills.repo.add")}
</Button>
</div>
</div>
{/* 仓库列表 */}
<div className="space-y-4">
<h3 className="text-base font-semibold text-foreground">
{t("skills.repo.list")}
</h3>
{repos.length === 0 ? (
<div className="text-center py-12 glass rounded-xl border border-white/10">
<p className="text-sm text-muted-foreground">
{t("skills.repo.empty")}
</p>
</div>
) : (
<div className="space-y-3">
{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"
>
<div>
<div className="text-sm font-medium text-foreground">
{repo.owner}/{repo.name}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
{repo.skillsPath && (
<>
<span className="mx-2"></span>
{t("skills.repo.path")}: {repo.skillsPath}
</>
)}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
})}
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => handleOpenRepo(repo.owner, repo.name)}
title={t("common.view", { defaultValue: "查看" })}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => onRemove(repo.owner, repo.name)}
title={t("common.delete")}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</FullScreenPanel>
);
}

View File

@@ -57,7 +57,8 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return (
<Card className="flex flex-col h-full border-border-default bg-card transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-md">
<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">
<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">
<div className="flex-1 min-w-0">
@@ -95,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-border-default">
<CardFooter className="flex gap-2 pt-3 border-t border-white/5 relative z-10">
{skill.readmeUrl && (
<Button
variant="ghost"

View File

@@ -1,190 +1,275 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo, forwardRef, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { RefreshCw, Settings } from "lucide-react";
import { Input } from "@/components/ui/input";
import { RefreshCw, Search } from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManager } from "./RepoManager";
import { RepoManagerPanel } from "./RepoManagerPanel";
import { skillsApi, type Skill, type SkillRepo } from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
onClose?: () => void;
}
export function SkillsPage({ onClose: _onClose }: SkillsPageProps = {}) {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
export interface SkillsPageHandle {
refresh: () => void;
openRepoManager: () => void;
}
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try {
setLoading(true);
const data = await skillsApi.getAll();
setSkills(data);
if (afterLoad) {
afterLoad(data);
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ onClose: _onClose }, ref) => {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => {
try {
setLoading(true);
const data = await skillsApi.getAll();
setSkills(data);
if (afterLoad) {
afterLoad(data);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 传入 "skills.loadFailed" 作为标题
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.loadFailed",
);
toast.error(title, {
description,
duration: 8000,
});
console.error("Load skills failed:", error);
} finally {
setLoading(false);
}
} catch (error) {
toast.error(t("skills.loadFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
};
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
}, []);
useImperativeHandle(ref, () => ({
refresh: () => loadSkills(),
openRepoManager: () => setRepoManagerOpen(true),
}));
const handleInstall = async (directory: string) => {
try {
await skillsApi.install(directory);
toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 使用错误解析器格式化错误,传入 "skills.installFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.installFailed",
);
toast.error(title, {
description,
duration: 10000, // 延长显示时间让用户看清
});
console.error("Install skill failed:", {
directory,
error,
message: errorMessage,
});
}
};
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory);
toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills();
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// 使用错误解析器格式化错误,传入 "skills.uninstallFailed"
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.uninstallFailed",
);
toast.error(title, {
description,
duration: 10000,
});
console.error("Uninstall skill failed:", {
directory,
error,
message: errorMessage,
});
}
};
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
);
};
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }));
await Promise.all([loadRepos(), loadSkills()]);
};
// 过滤技能列表
const filteredSkills = useMemo(() => {
if (!searchQuery.trim()) return skills;
const query = searchQuery.toLowerCase();
return skills.filter((skill) => {
const name = skill.name?.toLowerCase() || "";
const description = skill.description?.toLowerCase() || "";
const directory = skill.directory?.toLowerCase() || "";
return (
name.includes(query) ||
description.includes(query) ||
directory.includes(query)
);
});
} finally {
setLoading(false);
}
};
}, [skills, searchQuery]);
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
return (
<div className="flex flex-col h-full min-h-0 bg-background/50">
{/* 顶部操作栏(固定区域)已移除,由 App.tsx 接管 */}
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
}, []);
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 min-h-0 overflow-y-auto animate-fade-in">
<div className="mx-auto max-w-[56rem] px-6 py-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : (
<>
{/* 搜索框 */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("skills.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{searchQuery && (
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.count", { count: filteredSkills.length })}
</p>
)}
</div>
const handleInstall = async (directory: string) => {
try {
await skillsApi.install(directory);
toast.success(t("skills.installSuccess", { name: directory }));
await loadSkills();
} catch (error) {
toast.error(t("skills.installFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
}
};
const handleUninstall = async (directory: string) => {
try {
await skillsApi.uninstall(directory);
toast.success(t("skills.uninstallSuccess", { name: directory }));
await loadSkills();
} catch (error) {
toast.error(t("skills.uninstallFailed"), {
description: error instanceof Error ? error.message : t("common.error"),
});
}
};
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
);
};
const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }));
await Promise.all([loadRepos(), loadSkills()]);
};
return (
<div className="flex flex-col h-full min-h-0 bg-background">
{/* 顶部操作栏(固定区域) */}
<div className="flex-shrink-0 border-b border-border-default bg-muted/20 px-6 py-4">
<div className="flex items-center justify-between pr-8">
<h1 className="text-lg font-semibold leading-tight tracking-tight text-gray-900 dark:text-gray-100">
{t("skills.title")}
</h1>
<div className="flex gap-2">
<Button
variant="mcp"
size="sm"
onClick={() => loadSkills()}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
{loading ? t("skills.refreshing") : t("skills.refresh")}
</Button>
<Button
variant="mcp"
size="sm"
onClick={() => setRepoManagerOpen(true)}
>
<Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")}
</Button>
{/* 技能列表或无结果提示 */}
{filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("skills.emptyDescription")}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
)}
</>
)}
</div>
</div>
{/* 描述 */}
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
{t("skills.description")}
</p>
</div>
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6 bg-muted/10">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
{/* 仓库管理面板 */}
{repoManagerOpen && (
<RepoManagerPanel
repos={repos}
skills={skills}
onAdd={handleAddRepo}
onRemove={handleRemoveRepo}
onClose={() => setRepoManagerOpen(false)}
/>
)}
</div>
);
},
);
{/* 仓库管理对话框 */}
<RepoManager
open={repoManagerOpen}
onOpenChange={setRepoManagerOpen}
repos={repos}
skills={skills}
onAdd={handleAddRepo}
onRemove={handleRemoveRepo}
/>
</div>
);
}
SkillsPage.displayName = "SkillsPage";

View File

@@ -1,6 +1,5 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
@@ -14,13 +13,14 @@ const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
zIndex?: "base" | "nested" | "alert";
zIndex?: "base" | "nested" | "alert" | "top";
}
>(({ className, zIndex = "base", ...props }, ref) => {
const zIndexMap = {
base: "z-40",
nested: "z-50",
alert: "z-[60]",
top: "z-[110]",
};
return (
@@ -40,40 +40,54 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
zIndex?: "base" | "nested" | "alert";
zIndex?: "base" | "nested" | "alert" | "top";
variant?: "default" | "fullscreen";
overlayClassName?: string;
}
>(({ className, children, zIndex = "base", ...props }, ref) => {
const zIndexMap = {
base: "z-40",
nested: "z-50",
alert: "z-[60]",
};
>(
(
{
className,
children,
zIndex = "base",
variant = "default",
overlayClassName,
...props
},
ref,
) => {
const zIndexMap = {
base: "z-40",
nested: "z-50",
alert: "z-[60]",
top: "z-[110]",
};
return (
<DialogPortal>
<DialogOverlay zIndex={zIndex} />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-white dark:bg-gray-900 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
zIndexMap[zIndex],
className,
)}
onInteractOutside={(e) => {
// 防止点击遮罩层关闭对话框
e.preventDefault();
}}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
const variantClass = {
default:
"fixed left-1/2 top-1/2 flex flex-col w-full max-w-lg max-h-[90vh] translate-x-[-50%] translate-y-[-50%] border border-border-default bg-background text-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
fullscreen:
"fixed inset-0 flex flex-col w-screen h-screen translate-x-0 translate-y-0 bg-background text-foreground p-0 sm:rounded-none shadow-none",
}[variant];
return (
<DialogPortal>
<DialogOverlay zIndex={zIndex} className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(variantClass, zIndexMap[zIndex], className)}
onInteractOutside={(e) => {
// 防止点击遮罩层关闭对话框
e.preventDefault();
}}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
);
},
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({

View File

@@ -40,6 +40,9 @@ export interface ProviderPreset {
endpointCandidates?: string[];
// 新增:视觉主题配置
theme?: PresetTheme;
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
}
export const providerPresets: ProviderPreset[] = [
@@ -56,6 +59,8 @@ export const providerPresets: ProviderPreset[] = [
backgroundColor: "#D97757",
textColor: "#FFFFFF",
},
icon: "anthropic",
iconColor: "#D4915D",
},
{
name: "DeepSeek",

View File

@@ -0,0 +1,73 @@
/**
* 根据供应商名称智能推断图标配置
*/
const iconMappings = {
// AI 服务商
claude: { icon: "claude", iconColor: "#D4915D" },
anthropic: { icon: "anthropic", iconColor: "#D4915D" },
deepseek: { icon: "deepseek", iconColor: "#1E88E5" },
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
glm: { icon: "zhipu", iconColor: "#0F62FE" },
qwen: { icon: "qwen", iconColor: "#FF6A00" },
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
kimi: { icon: "kimi", iconColor: "#6366F1" },
moonshot: { icon: "moonshot", iconColor: "#6366F1" },
baidu: { icon: "baidu", iconColor: "#2932E1" },
tencent: { icon: "tencent", iconColor: "#00A4FF" },
hunyuan: { icon: "hunyuan", iconColor: "#00A4FF" },
minimax: { icon: "minimax", iconColor: "#FF6B6B" },
google: { icon: "google", iconColor: "#4285F4" },
meta: { icon: "meta", iconColor: "#0081FB" },
mistral: { icon: "mistral", iconColor: "#FF7000" },
cohere: { icon: "cohere", iconColor: "#39594D" },
perplexity: { icon: "perplexity", iconColor: "#20808D" },
huggingface: { icon: "huggingface", iconColor: "#FFD21E" },
// 云平台
aws: { icon: "aws", iconColor: "#FF9900" },
azure: { icon: "azure", iconColor: "#0078D4" },
huawei: { icon: "huawei", iconColor: "#FF0000" },
cloudflare: { icon: "cloudflare", iconColor: "#F38020" },
};
/**
* 根据预设名称推断图标
*/
export function inferIconForPreset(presetName: string): {
icon?: string;
iconColor?: string;
} {
const nameLower = presetName.toLowerCase();
// 精确匹配或模糊匹配
for (const [key, config] of Object.entries(iconMappings)) {
if (nameLower.includes(key)) {
return config;
}
}
return {};
}
/**
* 批量为预设添加图标配置
*/
export function addIconsToPresets<
T extends { name: string; icon?: string; iconColor?: string },
>(presets: T[]): T[] {
return presets.map((preset) => {
// 如果已经配置了图标,则保留原配置
if (preset.icon) {
return preset;
}
// 否则根据名称推断
const inferred = inferIconForPreset(preset.name);
return {
...preset,
...inferred,
};
});
}

View File

@@ -33,7 +33,13 @@ export interface UseSettingsResult {
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
saveSettings: () => Promise<SaveResult | null>;
saveSettings: (
overrides?: Partial<SettingsFormState>,
options?: { silent?: boolean },
) => Promise<SaveResult | null>;
autoSaveSettings: (
updates: Partial<SettingsFormState>,
) => Promise<SaveResult | null>;
resetSettings: () => void;
acknowledgeRestart: () => void;
}
@@ -114,97 +120,220 @@ export function useSettings(): UseSettingsResult {
setRequiresRestart,
]);
// 保存设置
const saveSettings = useCallback(async (): Promise<SaveResult | null> => {
if (!settings) return null;
try {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(settings.geminiConfigDir);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const payload: Settings = {
...settings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: settings.language,
};
await saveMutation.mutateAsync(payload);
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
// 即时保存设置(用于 General 标签页的实时更新)
// 保存基础配置 + 独立的系统 API 调用(开机自启)
const autoSaveSettings = useCallback(
async (updates: Partial<SettingsFormState>): Promise<SaveResult | null> => {
const mergedSettings = settings ? { ...settings, ...updates } : null;
if (!mergedSettings) return null;
try {
if (payload.enableClaudePluginIntegration) {
await settingsApi.applyClaudePluginConfig({ official: false });
} else {
await settingsApi.applyClaudePluginConfig({ official: true });
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: mergedSettings.language,
};
// 保存到配置文件
await saveMutation.mutateAsync(payload);
// 如果开机自启状态改变,调用系统 API
if (
payload.launchOnStartup !== undefined &&
payload.launchOnStartup !== data?.launchOnStartup
) {
try {
await settingsApi.setAutoLaunch(payload.launchOnStartup);
} catch (error) {
console.error("Failed to update auto-launch:", error);
toast.error(
t("settings.autoLaunchFailed", {
defaultValue: "设置开机自启失败",
}),
);
}
}
} catch (error) {
console.warn(
"[useSettings] Failed to sync Claude plugin config",
error,
);
toast.error(
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
}),
);
}
try {
if (typeof window !== "undefined") {
window.localStorage.setItem("language", payload.language as Language);
}
} catch (error) {
console.warn(
"[useSettings] Failed to persist language preference",
error,
);
}
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
// 持久化语言偏好
try {
if (typeof window !== "undefined" && updates.language) {
window.localStorage.setItem("language", updates.language);
}
} catch (error) {
console.warn(
"[useSettings] Failed to sync current providers after directory change",
syncResult.error,
"[useSettings] Failed to persist language preference",
error,
);
}
// 更新托盘菜单
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
return { requiresRestart: false };
} catch (error) {
console.error("[useSettings] Failed to auto-save settings", error);
toast.error(
t("notifications.settingsSaveFailed", {
defaultValue: "保存设置失败: {{error}}",
error: (error as Error)?.message ?? String(error),
}),
);
throw error;
}
},
[data, saveMutation, settings, t],
);
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
setRequiresRestart(appDirChanged);
// 完整保存设置(用于 Advanced 标签页的手动保存)
// 包含所有系统 API 调用和完整的验证流程
const saveSettings = useCallback(
async (
overrides?: Partial<SettingsFormState>,
options?: { silent?: boolean },
): Promise<SaveResult | null> => {
const mergedSettings = settings ? { ...settings, ...overrides } : null;
if (!mergedSettings) return null;
try {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
return { requiresRestart: appDirChanged };
} catch (error) {
console.error("[useSettings] Failed to save settings", error);
throw error;
}
}, [
appConfigDir,
data,
initialAppConfigDir,
saveMutation,
settings,
setRequiresRestart,
t,
]);
const payload: Settings = {
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: mergedSettings.language,
};
await saveMutation.mutateAsync(payload);
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
// 只在开机自启状态真正改变时调用系统 API
if (
payload.launchOnStartup !== undefined &&
payload.launchOnStartup !== data?.launchOnStartup
) {
try {
await settingsApi.setAutoLaunch(payload.launchOnStartup);
} catch (error) {
console.error("Failed to update auto-launch:", error);
toast.error(
t("settings.autoLaunchFailed", {
defaultValue: "设置开机自启失败",
}),
);
}
}
// 只在 Claude 插件集成状态真正改变时调用系统 API
if (
payload.enableClaudePluginIntegration !== undefined &&
payload.enableClaudePluginIntegration !==
data?.enableClaudePluginIntegration
) {
try {
if (payload.enableClaudePluginIntegration) {
await settingsApi.applyClaudePluginConfig({ official: false });
} else {
await settingsApi.applyClaudePluginConfig({ official: true });
}
} catch (error) {
console.warn(
"[useSettings] Failed to sync Claude plugin config",
error,
);
toast.error(
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
}),
);
}
}
try {
if (typeof window !== "undefined") {
window.localStorage.setItem(
"language",
payload.language as Language,
);
}
} catch (error) {
console.warn(
"[useSettings] Failed to persist language preference",
error,
);
}
try {
await providersApi.updateTrayMenu();
} catch (error) {
console.warn("[useSettings] Failed to refresh tray menu", error);
}
// 如果 Claude/Codex/Gemini 的目录覆盖发生变化,则立即将“当前使用的供应商”写回对应应用的 live 配置
const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir;
const codexDirChanged = sanitizedCodexDir !== previousCodexDir;
const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir;
if (claudeDirChanged || codexDirChanged || geminiDirChanged) {
const syncResult = await syncCurrentProvidersLiveSafe();
if (!syncResult.ok) {
console.warn(
"[useSettings] Failed to sync current providers after directory change",
syncResult.error,
);
}
}
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
setRequiresRestart(appDirChanged);
if (!options?.silent) {
toast.success(
t("notifications.settingsSaved", {
defaultValue: "设置已保存",
}),
);
}
return { requiresRestart: appDirChanged };
} catch (error) {
console.error("[useSettings] Failed to save settings", error);
toast.error(
t("notifications.settingsSaveFailed", {
defaultValue: "保存设置失败: {{error}}",
error: (error as Error)?.message ?? String(error),
}),
);
throw error;
}
},
[
appConfigDir,
data,
initialAppConfigDir,
saveMutation,
settings,
setRequiresRestart,
t,
],
);
const isLoading = useMemo(
() => isFormLoading || isDirectoryLoading || isMetadataLoading,
@@ -227,6 +356,7 @@ export function useSettings(): UseSettingsResult {
resetDirectory,
resetAppConfigDir,
saveSettings,
autoSaveSettings,
resetSettings,
acknowledgeRestart,
};

View File

@@ -27,7 +27,8 @@
"formatSuccess": "Formatted successfully",
"formatError": "Format failed: {{error}}",
"copy": "Copy",
"view": "View"
"view": "View",
"back": "Back"
},
"apiKeyInput": {
"placeholder": "Enter API Key",
@@ -166,6 +167,9 @@
"languageOptionEnglish": "English",
"windowBehavior": "Window Behavior",
"windowBehaviorHint": "Configure window minimize and Claude plugin integration policies.",
"launchOnStartup": "Launch on Startup",
"launchOnStartupDescription": "Automatically run CC Switch when system starts",
"autoLaunchFailed": "Failed to set auto-launch",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"enableClaudePluginIntegration": "Apply to Claude Code extension",
@@ -314,7 +318,8 @@
"pleaseAddEndpoint": "Please add an endpoint first",
"testUnavailable": "Speed test unavailable",
"noResult": "No result returned",
"testFailed": "Speed test failed: {{error}}"
"testFailed": "Speed test failed: {{error}}",
"status": "Status: {{code}}"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -361,6 +366,9 @@
"title": "Configure Usage Query",
"enableUsageQuery": "Enable usage query",
"presetTemplate": "Preset template",
"requestUrl": "Request URL",
"requestUrlPlaceholder": "e.g. https://api.example.com",
"method": "HTTP method",
"templateCustom": "Custom",
"templateGeneral": "General",
"templateNewAPI": "NewAPI",
@@ -373,11 +381,14 @@
"queryFailedMessage": "Query failed",
"queryScript": "Query script (JavaScript)",
"timeoutSeconds": "Timeout (seconds)",
"headers": "Headers",
"body": "Body",
"timeoutHint": "Range: 2-30 seconds",
"timeoutMustBeInteger": "Timeout must be an integer, decimal part ignored",
"timeoutCannotBeNegative": "Timeout cannot be negative",
"autoIntervalMinutes": "Auto query interval (minutes)",
"autoQueryInterval": "Auto Query Interval (minutes)",
"autoQueryIntervalHint": "0 to disable, recommend 5-60 minutes",
"autoQueryIntervalHint": "0 to disable; recommend 5-60 minutes",
"intervalMustBeInteger": "Interval must be an integer, decimal part ignored",
"intervalCannotBeNegative": "Interval cannot be negative",
"intervalAdjusted": "Interval adjusted to {{value}} minutes",
@@ -398,6 +409,9 @@
"formatSuccess": "Format successful",
"formatFailed": "Format failed",
"variablesHint": "Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object",
"scriptConfig": "Request configuration",
"extractorCode": "Extractor code",
"extractorHint": "Return object should include remaining quota fields",
"fieldIsValid": "• isValid: Boolean, whether plan is valid",
"fieldInvalidMessage": "• invalidMessage: String, reason for expiration (shown when isValid is false)",
"fieldRemaining": "• remaining: Number, remaining quota",
@@ -675,6 +689,34 @@
"installFailed": "Failed to install",
"uninstallSuccess": "Skill {{name}} uninstalled",
"uninstallFailed": "Failed to uninstall",
"error": {
"skillNotFound": "Skill not found: {{directory}}",
"missingRepoInfo": "Missing repository info (owner or name)",
"downloadTimeout": "Download repository {{owner}}/{{name}} timeout ({{timeout}}s)",
"downloadTimeoutHint": "Please check network connection or retry later",
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
"skillDirNotFound": "Skill directory not found: {{path}}",
"emptyArchive": "Downloaded archive is empty",
"downloadFailed": "Download failed: HTTP {{status}}",
"allBranchesFailed": "All branches failed, tried: {{branches}}",
"httpError": "HTTP error {{status}}",
"http403": "GitHub access restricted, possibly rate limited",
"http404": "Repository or branch not found, please check URL",
"http429": "Too many requests, please wait and retry",
"parseMetadataFailed": "Failed to parse skill metadata",
"getHomeDirFailed": "Unable to get user home directory",
"networkError": "Network error",
"fsError": "File system error",
"unknownError": "Unknown error",
"suggestion": {
"checkNetwork": "Please check network connection",
"checkProxy": "Consider configuring HTTP proxy",
"retryLater": "Please retry later",
"checkRepoUrl": "Please check repository URL and branch name",
"checkDiskSpace": "Please check disk space",
"checkPermission": "Please check directory permissions"
}
},
"repo": {
"title": "Manage Skill Repositories",
"description": "Add or remove GitHub skill repository sources",
@@ -693,7 +735,10 @@
"removeSuccess": "Repository {{owner}}/{{name}} removed",
"removeFailed": "Failed to remove",
"skillCount": "{{count}} skills detected"
}
},
"search": "Search Skills",
"searchPlaceholder": "Search skill name or description...",
"noResults": "No matching skills found"
},
"deeplink": {
"confirmImport": "Confirm Import Provider",
@@ -711,6 +756,29 @@
"parseError": "Failed to parse deep link",
"importSuccess": "Import successful",
"importSuccessDescription": "Provider \"{{name}}\" has been successfully imported",
"importError": "Failed to import"
"importError": "Failed to import",
"configSource": "Config Source",
"configEmbedded": "Embedded Config",
"configRemote": "Remote Config",
"configDetails": "Config Details",
"configUrl": "Config File URL",
"configMergeError": "Failed to merge configuration file"
},
"iconPicker": {
"search": "Search Icons",
"searchPlaceholder": "Enter icon name...",
"noResults": "No matching icons found",
"category": {
"aiProvider": "AI Providers",
"cloud": "Cloud Platforms",
"tool": "Dev Tools",
"other": "Other"
}
},
"providerIcon": {
"label": "Icon",
"colorLabel": "Icon Color",
"selectIcon": "Select Icon",
"preview": "Preview"
}
}

View File

@@ -27,7 +27,8 @@
"formatSuccess": "格式化成功",
"formatError": "格式化失败:{{error}}",
"copy": "复制",
"view": "查看"
"view": "查看",
"back": "返回"
},
"apiKeyInput": {
"placeholder": "请输入API Key",
@@ -166,6 +167,9 @@
"languageOptionEnglish": "English",
"windowBehavior": "窗口行为",
"windowBehaviorHint": "配置窗口最小化与 Claude 插件联动策略。",
"launchOnStartup": "开机自启",
"launchOnStartupDescription": "随系统启动自动运行 CC Switch",
"autoLaunchFailed": "设置开机自启失败",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
@@ -314,7 +318,8 @@
"pleaseAddEndpoint": "请先添加端点",
"testUnavailable": "测速功能不可用",
"noResult": "未返回结果",
"testFailed": "测速失败: {{error}}"
"testFailed": "测速失败: {{error}}",
"status": "状态码:{{code}}"
},
"codexConfig": {
"authJson": "auth.json (JSON) *",
@@ -361,6 +366,9 @@
"title": "配置用量查询",
"enableUsageQuery": "启用用量查询",
"presetTemplate": "预设模板",
"requestUrl": "请求地址",
"requestUrlPlaceholder": "例如https://api.example.com",
"method": "HTTP 方法",
"templateCustom": "自定义",
"templateGeneral": "通用模板",
"templateNewAPI": "NewAPI",
@@ -373,11 +381,14 @@
"queryFailedMessage": "查询失败",
"queryScript": "查询脚本JavaScript",
"timeoutSeconds": "超时时间(秒)",
"headers": "请求头",
"body": "请求 Body",
"timeoutHint": "范围: 2-30 秒",
"timeoutMustBeInteger": "超时时间必须为整数,小数部分已忽略",
"timeoutCannotBeNegative": "超时时间不能为负数",
"autoIntervalMinutes": "自动查询间隔(分钟)",
"autoQueryInterval": "自动查询间隔(分钟)",
"autoQueryIntervalHint": "0 表示不自动查询,建议设置 5-60 分钟",
"autoQueryIntervalHint": "0 表示不自动查询,建议 5-60 分钟",
"intervalMustBeInteger": "自动查询间隔必须为整数,小数部分已忽略",
"intervalCannotBeNegative": "自动查询间隔不能为负数",
"intervalAdjusted": "自动查询间隔已调整为 {{value}} 分钟",
@@ -398,6 +409,9 @@
"formatSuccess": "格式化成功",
"formatFailed": "格式化失败",
"variablesHint": "支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象",
"scriptConfig": "请求配置",
"extractorCode": "提取器代码",
"extractorHint": "返回对象需包含剩余额度等字段",
"fieldIsValid": "• isValid: 布尔值,套餐是否有效",
"fieldInvalidMessage": "• invalidMessage: 字符串,失效原因说明(当 isValid 为 false 时显示)",
"fieldRemaining": "• remaining: 数字,剩余额度",
@@ -675,6 +689,34 @@
"installFailed": "安装失败",
"uninstallSuccess": "技能 {{name}} 已卸载",
"uninstallFailed": "卸载失败",
"error": {
"skillNotFound": "技能不存在:{{directory}}",
"missingRepoInfo": "缺少仓库信息owner 或 name",
"downloadTimeout": "下载仓库 {{owner}}/{{name}} 超时({{timeout}}秒)",
"downloadTimeoutHint": "请检查网络连接或稍后重试",
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
"skillDirNotFound": "技能目录不存在:{{path}}",
"emptyArchive": "下载的压缩包为空",
"downloadFailed": "下载失败HTTP {{status}}",
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
"httpError": "HTTP 错误 {{status}}",
"http403": "GitHub 访问受限,可能是请求频率过高",
"http404": "仓库或分支不存在,请检查地址",
"http429": "请求过于频繁,请等待后重试",
"parseMetadataFailed": "解析技能元数据失败",
"getHomeDirFailed": "无法获取用户主目录",
"networkError": "网络错误",
"fsError": "文件系统错误",
"unknownError": "未知错误",
"suggestion": {
"checkNetwork": "请检查网络连接",
"checkProxy": "建议配置 HTTP 代理",
"retryLater": "请稍后重试",
"checkRepoUrl": "请检查仓库地址和分支名称",
"checkDiskSpace": "请检查磁盘空间",
"checkPermission": "请检查目录权限"
}
},
"repo": {
"title": "管理技能仓库",
"description": "添加或删除 GitHub 技能仓库源",
@@ -693,7 +735,10 @@
"removeSuccess": "仓库 {{owner}}/{{name}} 已删除",
"removeFailed": "删除失败",
"skillCount": "识别到 {{count}} 个技能"
}
},
"search": "搜索技能",
"searchPlaceholder": "搜索技能名称或描述...",
"noResults": "未找到匹配的技能"
},
"deeplink": {
"confirmImport": "确认导入供应商配置",
@@ -711,6 +756,29 @@
"parseError": "深链接解析失败",
"importSuccess": "导入成功",
"importSuccessDescription": "供应商 \"{{name}}\" 已成功导入",
"importError": "导入失败"
"importError": "导入失败",
"configSource": "配置来源",
"configEmbedded": "内嵌配置",
"configRemote": "远程配置",
"configDetails": "配置详情",
"configUrl": "配置文件 URL",
"configMergeError": "合并配置文件失败"
},
"iconPicker": {
"search": "搜索图标",
"searchPlaceholder": "输入图标名称...",
"noResults": "未找到匹配的图标",
"category": {
"aiProvider": "AI 服务商",
"cloud": "云平台",
"tool": "开发工具",
"other": "其他"
}
},
"providerIcon": {
"label": "图标",
"colorLabel": "图标颜色",
"selectIcon": "选择图标",
"preview": "预览"
}
}

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Alibaba</title><path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z" fill="#FF6003" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AWS</title><path d="M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z"></path><path d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z" fill="#F90"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ByteDance</title><path d="M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z" fill="#00C8D2" fill-rule="nonzero"></path><path d="M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z" fill="#3C8CFF" fill-rule="nonzero"></path><path d="M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z" fill="#78E6DC" fill-rule="nonzero"></path><path d="M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z" fill="#325AB4"></path></svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ChatGLM</title><defs><linearGradient id="lobe-icons-chat-glm-fill" x1="-18.756%" x2="70.894%" y1="49.371%" y2="90.944%"><stop offset="0%" stop-color="#504AF4"></stop><stop offset="100%" stop-color="#3485FF"></stop></linearGradient></defs><path d="M9.917 2c4.906 0 10.178 3.947 8.93 10.58-.014.07-.037.14-.057.21l-.003-.277c-.083-3-1.534-8.934-8.87-8.934-3.393 0-8.137 3.054-7.93 8.158-.04 4.778 3.555 8.4 7.95 8.332l.073-.001c1.2-.033 2.763-.429 3.1-1.657.063-.031.26.534.268.598.048.256.112.369.192.34.981-.348 2.286-1.222 1.952-2.38-.176-.61-1.775-.147-1.921-.347.418-.979 2.234-.926 3.153-.716.443.102.657.38 1.012.442.29.052.981-.2.96.242-1.5 3.042-4.893 5.41-8.808 5.41C3.654 22 0 16.574 0 11.737 0 5.947 4.959 2 9.917 2zM9.9 5.3c.484 0 1.125.225 1.38.585 3.669.145 4.313 2.686 4.694 5.444.255 1.838.315 2.3.182 1.387l.083.59c.068.448.554.737.982.516.144-.075.254-.231.328-.47a.2.2 0 01.258-.13l.625.22a.2.2 0 01.124.238 2.172 2.172 0 01-.51.92c-.878.917-2.757.664-3.08-.62-.14-.554-.055-.626-.345-1.242-.292-.621-1.238-.709-1.69-.295-.345.315-.407.805-.406 1.282L12.6 15.9a.9.9 0 01-.9.9h-1.4a.9.9 0 01-.9-.9v-.65a1.15 1.15 0 10-2.3 0v.65a.9.9 0 01-.9.9H4.8a.9.9 0 01-.9-.9l.035-3.239c.012-1.884.356-3.658 2.47-4.134.2-.045.252.13.29.342.025.154.043.252.053.294.701 3.058 1.75 4.299 3.144 3.722l.66-.331.254-.13c.158-.082.25-.131.276-.15.012-.01-.165-.206-.407-.464l-1.012-1.067a8.925 8.925 0 01-.199-.216c-.047-.034-.116.068-.208.306-.074.157-.251.252-.272.326-.013.058.108.298.362.72.164.288.22.508-.31.343-1.04-.8-1.518-2.273-1.684-3.725-.004-.035-.162-1.913-.162-1.913a1.2 1.2 0 011.113-1.281L9.9 5.3zm12.994 8.68c.037.697-.403.704-1.213.591l-1.783-.276c-.265-.053-.385-.099-.313-.147.47-.315 3.268-.93 3.31-.168zm-.915-.083l-.926.042c-.85.077-1.452.24.338.336l.103.003c.815.012 1.264-.359.485-.381zm1.667-3.601h.01c.79.398.067 1.03-.65 1.393-.14.07-.491.176-1.052.315-.241.04-.457.092-.333.16l.01.005c1.952.958-3.123 1.534-2.495 1.285l.38-.148c.68-.266 1.614-.682 1.666-1.337.038-.48 1.253-.442 1.493-.968.048-.106 0-.236-.144-.389-.05-.047-.094-.094-.107-.148-.073-.305.7-.431 1.222-.168zm-2.568-.474c-.135 1.198-2.479 4.192-1.949 2.863l.017-.042c.298-.717.376-2.221 1.337-3.221.25-.26.636.035.595.4zm-7.976-.253c.02-.694 1.002-.968 1.346-.347.01-1.274-1.941-.768-1.346.347z" fill="url(#lobe-icons-chat-glm-fill)" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437" fill="#F38020"></path><path d="M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777" fill="#FCAD32"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>

After

Width:  |  Height:  |  Size: 769 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Copilot</title><path d="M17.533 1.829A2.528 2.528 0 0015.11 0h-.737a2.531 2.531 0 00-2.484 2.087l-1.263 6.937.314-1.08a2.528 2.528 0 012.424-1.833h4.284l1.797.706 1.731-.706h-.505a2.528 2.528 0 01-2.423-1.829l-.715-2.453z" fill="url(#lobe-icons-copilot-fill-0)" transform="translate(0 1)"></path><path d="M6.726 20.16A2.528 2.528 0 009.152 22h1.566c1.37 0 2.49-1.1 2.525-2.48l.17-6.69-.357 1.228a2.528 2.528 0 01-2.423 1.83h-4.32l-1.54-.842-1.667.843h.497c1.124 0 2.113.75 2.426 1.84l.697 2.432z" fill="url(#lobe-icons-copilot-fill-1)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-2)" transform="translate(0 1)"></path><path d="M15 0H6.252c-2.5 0-4 3.331-5 6.662-1.184 3.947-2.734 9.225 1.75 9.225H6.78c1.13 0 2.12-.753 2.43-1.847.657-2.317 1.809-6.359 2.713-9.436.46-1.563.842-2.906 1.43-3.742A1.97 1.97 0 0115 0" fill="url(#lobe-icons-copilot-fill-3)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-4)" transform="translate(0 1)"></path><path d="M9 22h8.749c2.5 0 4-3.332 5-6.663 1.184-3.948 2.734-9.227-1.75-9.227H17.22c-1.129 0-2.12.754-2.43 1.848a1149.2 1149.2 0 01-2.713 9.437c-.46 1.564-.842 2.907-1.43 3.743A1.97 1.97 0 019 22" fill="url(#lobe-icons-copilot-fill-5)" transform="translate(0 1)"></path><defs><radialGradient cx="85.44%" cy="100.653%" fx="85.44%" fy="100.653%" gradientTransform="scale(-.8553 -1) rotate(50.927 2.041 -1.946)" id="lobe-icons-copilot-fill-0" r="105.116%"><stop offset="9.6%" stop-color="#00AEFF"></stop><stop offset="77.3%" stop-color="#2253CE"></stop><stop offset="100%" stop-color="#0736C4"></stop></radialGradient><radialGradient cx="18.143%" cy="32.928%" fx="18.143%" fy="32.928%" gradientTransform="scale(.8897 1) rotate(52.069 .193 .352)" id="lobe-icons-copilot-fill-1" r="95.612%"><stop offset="0%" stop-color="#FFB657"></stop><stop offset="63.4%" stop-color="#FF5F3D"></stop><stop offset="92.3%" stop-color="#C02B3C"></stop></radialGradient><radialGradient cx="82.987%" cy="-9.792%" fx="82.987%" fy="-9.792%" gradientTransform="scale(-1 -.9441) rotate(-70.872 .142 1.17)" id="lobe-icons-copilot-fill-4" r="140.622%"><stop offset="6.6%" stop-color="#8C48FF"></stop><stop offset="50%" stop-color="#F2598A"></stop><stop offset="89.6%" stop-color="#FFB152"></stop></radialGradient><linearGradient id="lobe-icons-copilot-fill-2" x1="39.465%" x2="46.884%" y1="12.117%" y2="103.774%"><stop offset="15.6%" stop-color="#0D91E1"></stop><stop offset="48.7%" stop-color="#52B471"></stop><stop offset="65.2%" stop-color="#98BD42"></stop><stop offset="93.7%" stop-color="#FFC800"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-3" x1="45.949%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#3DCBFF"></stop><stop offset="24.7%" stop-color="#0588F7" stop-opacity="0"></stop></linearGradient><linearGradient id="lobe-icons-copilot-fill-5" x1="83.507%" x2="83.453%" y1="-6.106%" y2="21.131%"><stop offset="5.8%" stop-color="#F8ADFA"></stop><stop offset="70.8%" stop-color="#A86EDD" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

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